Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions apps/docs/app/llms-full.txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,41 @@ import { source } from '@/lib/source'

const logger = createLogger('DocsLlmsFullText')

export const revalidate = false
export const dynamic = 'force-dynamic'

export async function GET() {
try {
const pages = source.getPages().filter((page) => Boolean(page?.data && page.url))

const scan = pages.map((page) => getLLMText(page))
const scanned = await Promise.all(scan)

const filtered = scanned.filter((text) => text && text.length > 0)

return new Response(filtered.join('\n\n---\n\n'), {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
const encoder = new TextEncoder()
let nextPage = 0
let hasContent = false
let cancelled = false
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
while (nextPage < pages.length) {
const page = pages[nextPage]
nextPage += 1
const text = await getLLMText(page)
if (cancelled) return
if (!text) continue
controller.enqueue(encoder.encode(`${hasContent ? '\n\n---\n\n' : ''}${text}`))
hasContent = true
return
}
controller.close()
} catch (error) {
if (cancelled) return
logger.error('Error streaming LLM full text:', error)
controller.error(error)
}
},
cancel() {
cancelled = true
},
})
return new Response(body, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
})
} catch (error) {
logger.error('Error generating LLM full text:', error)
Expand Down
8 changes: 8 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10124,3 +10124,11 @@ export function YouComIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}

export function PlaneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 85 52' fill='currentColor' {...props}>
<path d='M44.322 2.926C44.322 0.755 46.608 -0.658 48.551 0.313L80.455 16.265C82.929 17.502 84.492 20.032 84.492 22.799V48.249C84.492 50.42 82.207 51.833 80.265 50.862L62.328 41.893V22.797C62.328 20.031 60.765 17.502 58.291 16.264L44.322 9.28V2.926ZM0 2.925C0 0.754 2.286 -0.659 4.229 0.312L22.158 9.277V28.377C22.158 31.143 23.721 33.672 26.195 34.909L40.17 41.895V48.249C40.17 50.42 37.885 51.832 35.942 50.861L4.037 34.909C1.563 33.672 0 31.143 0 28.377V2.925ZM22.158 2.925C22.158 0.754 24.444 -0.659 26.387 0.312L44.322 9.28V28.378C44.322 31.144 45.886 33.673 48.36 34.91L62.328 41.893V48.249C62.328 50.42 60.043 51.832 58.101 50.861L40.17 41.895V22.797C40.17 20.031 38.607 17.502 36.133 16.264L22.158 9.277V2.925Z' />
</svg>
)
}
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ import {
PineconeIcon,
PipedriveIcon,
PitchBookIcon,
PlaneIcon,
PlanetScaleIcon,
PolymarketIcon,
PostgresIcon,
Expand Down Expand Up @@ -558,6 +559,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
pinecone: PineconeIcon,
pipedrive: PipedriveIcon,
pitchbook: PitchBookIcon,
plane: PlaneIcon,
planetscale: PlanetScaleIcon,
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
"pipedrive",
"pipedrive-service-account",
"pitchbook",
"plane",
"planetscale",
"polymarket",
"postgresql",
Expand Down
5,985 changes: 5,985 additions & 0 deletions apps/docs/content/docs/integrations/plane.mdx

Large diffs are not rendered by default.

159 changes: 158 additions & 1 deletion apps/sim/app/api/webhooks/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ import {
telemetryMock,
workflowAuthzMockFns,
} from '@sim/testing'
import { toRecord } from '@sim/utils/object'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
activateExternalWebhookSubscription: vi.fn(),
cleanupExternalWebhook: vi.fn(),
authorizeCredentialUseForAuth: vi.fn(),
configurePolling: vi.fn(),
createExternalWebhookSubscription: vi.fn(),
Expand All @@ -35,7 +38,8 @@ vi.mock('@/lib/webhooks/env-resolver', () => ({
resolveEnvVarsInObject: mocks.resolveEnvVarsInObject,
}))
vi.mock('@/lib/webhooks/provider-subscriptions', () => ({
cleanupExternalWebhook: vi.fn(),
activateExternalWebhookSubscription: mocks.activateExternalWebhookSubscription,
cleanupExternalWebhook: mocks.cleanupExternalWebhook,
createExternalWebhookSubscription: mocks.createExternalWebhookSubscription,
shouldRecreateExternalWebhookSubscription: mocks.shouldRecreateExternalWebhookSubscription,
}))
Expand Down Expand Up @@ -462,3 +466,156 @@ describe('POST /api/webhooks credential references', () => {
expect(mocks.authorizeCredentialUseForAuth).not.toHaveBeenCalled()
})
})

describe('POST /api/webhooks subscription replacement recovery', () => {
beforeEach(setupUpsertMocks)
it.each(['create', 'save'])(
'preserves the live subscription when replacement fails at %s',
async (failure) => {
queueUpdatePathRows(true, {
autoRegister: true,
externalId: 'external-old',
webhookSecret: 'old-secret',
})
mocks.getProviderHandler.mockReturnValue({ activateSubscription: async () => undefined })
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(true)
const external = new Set(['external-old'])
mocks.createExternalWebhookSubscription.mockImplementation(async () => {
if (failure === 'create') throw new Error('creation unavailable')
external.add('external-new')
return {
updatedProviderConfig: {
autoRegister: true,
externalId: 'external-new',
webhookSecret: 'new-secret',
subscriptionActivationPending: true,
},
externalSubscriptionCreated: true,
}
})
if (failure === 'save')
dbChainMockFns.set.mockImplementation(() => {
throw new Error('database unavailable')
})
mocks.cleanupExternalWebhook.mockImplementation(async (row) => {
external.delete(String(toRecord(row.providerConfig).externalId))
})
expect((await POST(upsertRequest({ autoRegister: true }))).status).toBe(500)
expect([...external]).toEqual(['external-old'])
}
)
it.each([false, true])(
'retains recoverable state when prior cleanup fails: %s',
async (priorCleanupFails) => {
let cleanupUnavailable = priorCleanupFails
let activationMustFail = !priorCleanupFails
let nextExternalId = 0
let persisted: Record<string, unknown> = {
id: 'webhook-1',
workflowId: 'workflow-1',
blockId: 'block-1',
path: 'inbound-orders',
provider: 'plane',
isActive: true,
archivedAt: null,
providerConfig: {
autoRegister: true,
projectId: 'prior',
externalId: 'external-old',
webhookSecret: 'old-secret',
},
}
const external = new Map<string, { active: boolean }>([['external-old', { active: true }]])
const desired = { autoRegister: true, projectId: 'next' }
mocks.getProviderHandler.mockReturnValue({
createSubscription: async () => undefined,
activateSubscription: async () => undefined,
})
mocks.shouldRecreateExternalWebhookSubscription.mockImplementation(
({ previousConfig, nextConfig }) => previousConfig.projectId !== nextConfig.projectId
)
const defaultSet = dbChainMockFns.set.getMockImplementation()
dbChainMockFns.set.mockImplementation((...args: unknown[]) => {
persisted = { ...persisted, ...structuredClone(toRecord(args[0])) }
return defaultSet?.(...args)
})
dbChainMockFns.returning.mockImplementation(async () => [structuredClone(persisted)])
mocks.createExternalWebhookSubscription.mockImplementation(async (_request, row) => {
const externalId = `external-${++nextExternalId}`
external.set(externalId, { active: false })
return {
updatedProviderConfig: {
...toRecord(row.providerConfig),
externalId,
webhookSecret: 'new-secret',
subscriptionActivationPending: true,
},
externalSubscriptionCreated: true,
}
})
mocks.activateExternalWebhookSubscription.mockImplementation(async (_request, row) => {
const resource = external.get(String(toRecord(row.providerConfig).externalId))
if (!resource) throw new Error('provider subscription missing')
if (activationMustFail) {
activationMustFail = false
throw new Error('activation unavailable')
}
resource.active = true
})
mocks.cleanupExternalWebhook.mockImplementation(
async (row, _workflow, _requestId, options) => {
const externalId = String(toRecord(row.providerConfig).externalId)
if (cleanupUnavailable && externalId === 'external-old') {
if (options?.throwOnError) throw new Error('cleanup unavailable')
return
}
external.delete(externalId)
}
)
function queueCurrentRows(): void {
queueTableRows(workflow, [
{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'workspace-1' },
])
queueTableRows(webhook, [{ id: 'webhook-1' }])
queueTableRows(webhook, [structuredClone(persisted)])
}
function replacementRequest() {
return createMockRequest('POST', {
workflowId: 'workflow-1',
path: 'inbound-orders',
provider: 'plane',
providerConfig: desired,
})
}
queueCurrentRows()
expect((await POST(replacementRequest())).status).toBe(500)
expect(external.size).toBe(2)
const retainedConfig = toRecord(persisted.providerConfig)
if (priorCleanupFails) {
expect(retainedConfig.projectId).toBe('next')
expect(toRecord(retainedConfig.previousSubscription).providerConfig).toEqual({
autoRegister: true,
projectId: 'prior',
externalId: 'external-old',
webhookSecret: 'old-secret',
})
expect(external.get('external-old')?.active).toBe(true)
expect(external.get(String(retainedConfig.externalId))?.active).toBe(true)
} else {
expect(retainedConfig.projectId).toBe('next')
expect(retainedConfig.webhookSecret).toBe('new-secret')
expect(external.get('external-old')?.active).toBe(true)
expect(external.get(String(retainedConfig.externalId))?.active).toBe(false)
}
cleanupUnavailable = false
queueCurrentRows()
expect((await POST(replacementRequest())).status).toBe(200)
expect(external.size).toBe(1)
const recoveredConfig = toRecord(persisted.providerConfig)
expect(recoveredConfig.projectId).toBe('next')
expect(recoveredConfig.webhookSecret).toBe('new-secret')
expect(external.get(String(recoveredConfig.externalId))?.active).toBe(true)
if (!priorCleanupFails) expect(recoveredConfig.externalId).toBe(retainedConfig.externalId)
}
)
})
Loading
Loading