.github/9
ISSUE_TEMPLATE/3bug_report.md config.yml feature_request.md
workflows/3ci.yml clean-clone.yml release.yml
CODEOWNERS dependabot.yml pull_request_template.md
apps/630
api/250
src/243
common/30
__tests__/3authorize.test.ts bigint-serialization.interceptor.test.ts files-limits.test.ts
filters/1all-exceptions.filter.ts
guards/3auth.guard.ts internal-service.guard.ts rate-limit.guard.ts
interceptors/2actor-context.interceptor.ts bigint-serialization.interceptor.ts
middleware/2idempotency.test.ts idempotency.ts
pipes/1zod-validation.pipe.ts
actor-context.ts audit.service.ts cache.test.ts cache.ts cookies.ts core.module.ts deprecation.ts email.service.ts errors.ts event-bus.ts log-scrubber.test.ts log-scrubber.ts logger.ts permission-cache-bridge.ts prisma.service.ts redis.service.ts trace-metadata.ts types.ts
config/1env.ts
lib/11
__tests__/5crypto.test.ts filter-builder.test.ts jwt.test.ts pagination.test.ts positions.test.ts
authorize.ts crypto.ts filter-builder.ts jwt.ts pagination.ts positions.ts
modules/197
ai/28
__tests__/4ai-writing.test.ts database-ai.test.ts generate-executor.test.ts prompts.test.ts
generator/4executor.ts generate.controller.ts generate.dto.ts generate.service.ts
ai-chat.service.ts ai-rate-limit.ts ai-writing.controller.ts ai-writing.dto.ts ai-writing.service.ts ai.controller.ts ai.module.ts ai.service.ts budget-guard.service.ts database-ai.controller.ts database-ai.dto.ts database-ai.service.ts db-prompts.ts dto.ts gateway-provider.ts ledger.service.ts prompts.ts rag.controller.ts rag.dto.ts rag.service.ts
api-keys/3api-keys.controller.ts api-keys.module.ts api-keys.service.ts
audit/2audit-log.controller.ts audit.module.ts
auth/4auth.controller.ts auth.module.ts auth.service.ts dto.ts
automations/5automations.controller.ts automations.module.ts automations.service.ts dto.ts webhook-incoming.controller.ts
blocks/5
__tests__/1batch-version-cas.test.ts
block.service.ts blocks.controller.ts blocks.module.ts dto.ts
comments/4comments.controller.ts comments.module.ts comments.service.ts dto.ts
databases/9
__tests__/3normalize-select-options.test.ts option-id-canonicalization.test.ts record-open-query-shape.test.ts
database.service.ts databases.controller.ts databases.module.ts dto.ts property-cache-schema.ts query.service.ts
docs/2docs.controller.ts docs.module.ts
files/10
__tests__/1quota.test.ts
dto.ts files.controller.ts files.module.ts files.service.ts quota.ts record.ts s3.ts uploads.controller.ts uploads.service.ts
forms/18debug.controller.ts form.dto.ts forms.controller.ts forms.module.ts forms.service.ts markdown.test.ts markdown.ts optional-auth.guard.ts public-forms.controller.ts public-forms.service.ts question.dto.ts questions.service.ts rate-limit.ts recaptcha.ts slug.ts submissions.controller.ts submissions.service.ts submit-schema.ts
health/3health.controller.ts health.module.ts health.service.ts
integrations/3integrations.controller.ts integrations.module.ts integrations.service.ts
internal/12
__tests__/2flush-query-shape.test.ts internal.endpoints.test.ts
ai-test-seam.controller.ts ai-test-seam.dto.ts ai-test-seam.service.ts internal.controller.ts internal.dto.ts internal.module.ts internal.service.ts test-seam.controller.ts test-seam.dto.ts test-seam.service.ts
notifications/3notifications.controller.ts notifications.module.ts notifications.service.ts
oauth/6
__tests__/1oauth.scopes.test.ts
oauth.controller.ts oauth.module.ts oauth.service.ts scopes.ts token-keys.ts
org/15audit.service.ts embed-preview.controller.ts guards.ts members.service.ts org-admin.controller.ts org-policy.service.ts org.module.ts overview.service.ts policy-effective.controller.ts policy.schema.ts policy.test.ts retention.service.ts totp.test.ts totp.ts two-factor.service.ts
orgs/5dto.ts orgs.controller.ts orgs.module.ts orgs.service.ts util.ts
pages/4dto.ts page.service.ts pages.controller.ts pages.module.ts
scim/15
__tests__/3email-mapping.test.ts etag.test.ts filters.test.ts
etag.ts filters.ts groups.service.ts schemas.ts scim-admin.controller.ts scim-admin.service.ts scim-auth.guard.ts scim-auth.service.ts scim-errors.ts scim.controller.ts scim.module.ts users.service.ts
search/4
__tests__/1search.service.test.ts
search.controller.ts search.module.ts search.service.ts
sessions/3sessions.controller.ts sessions.module.ts sessions.service.ts
share/3share.controller.ts share.module.ts share.service.ts
sso/16
__tests__/4enforcement.test.ts oidc.verify.test.ts provisioning.test.ts saml.validate.test.ts
enforcement.ts oidc.controller.ts oidc.service.ts oidc.ts provisioning.service.ts saml.controller.ts saml.ts schemas.ts sso-admin.controller.ts sso-admin.service.ts sso-secrets.ts sso.module.ts
teams/4dto.ts teams.controller.ts teams.module.ts teams.service.ts
users/4dto.ts users.controller.ts users.module.ts users.service.ts
webhooks/3webhooks.controller.ts webhooks.module.ts webhooks.service.ts
workspaces/4dto.ts workspaces.controller.ts workspaces.module.ts workspaces.service.ts
types/1xmldom.d.ts
app.module.ts constants.ts main.ts
_probe_rawbody.cjs .env.example Dockerfile package.json README.md tsconfig.json vitest.config.ts
realtime/33
src/17auth.ts comments.ts config.ts event-source.ts http-store.ts index.ts metrics.ts notifications.ts page-updated-source.ts presence.ts prisma-store.ts protocol.ts rate-limit.ts room-key.ts rooms.ts server.ts update-log.ts
test/11auth.test.ts config.test.ts event-source.test.ts http-store.test.ts metrics-merge.test.ts page-updated-source.test.ts presence.test.ts protocol.test.ts rate-limit.test.ts rooms.test.ts task-0094-builders.test.ts
.env.example Dockerfile package.json README.md tsconfig.json
web/255
src/244
app/45
__tests__/1theme-tokens.test.ts
(app)/26
w/25
[workspace]/25
ask/1page.tsx
databases/1
[id]/1page.tsx
favorites/1page.tsx
files/1page.tsx
notifications/1page.tsx
page/4
[id]/4
[block]/1page.tsx
share/1page.tsx
layout.tsx page.tsx
search/1page.tsx
settings/10
appearance/1page.tsx
automations/3
[id]/1page.tsx
new/1page.tsx
page.tsx
billing/1page.tsx
developers/1page.tsx
members/1page.tsx
permissions/1page.tsx
teams/1page.tsx
page.tsx
shared/1page.tsx
templates/1page.tsx
trash/1page.tsx
layout.tsx page.tsx
layout.tsx
(auth)/4
login/1page.tsx
register/1page.tsx
reset-password/1page.tsx
layout.tsx
api/2
metrics/1route.ts
v1/1
health/1route.ts
f/1
[slug]/1page.tsx
health/1
ready/1route.ts
org/2
[orgId]/2
admin/2
integrations/1page.tsx
page.tsx
share/1
[id]/1page.tsx
error.tsx global-error.tsx globals.css layout.tsx not-found.tsx page.tsx providers.tsx
components/118
admin/10
org-admin/8ApiKeysTab.tsx AuditTab.tsx MembersTab.tsx OverviewTab.tsx PoliciesTab.tsx RetentionTab.tsx WebhooksTab.tsx WorkspacePicker.tsx
ScimCard.tsx SsoTabs.tsx
ai/6ai-panel.tsx ask-ai-submenu.tsx db-ai-dialog.tsx generate-page-dialog.tsx writing-actions-menu.tsx writing-diff.tsx
automations/5action-editor.tsx condition-editor.tsx lib.ts run-history.tsx trigger-editor.tsx
collaboration/6CursorLayer.tsx OfflineBanner.tsx PresenceBar.tsx README.md ReviewFailedDialog.tsx StatusChip.tsx
comments/3comment-panel.tsx comment-thread.tsx mention-input.tsx
database/27
__tests__/2cell-editor.test.tsx cell-renderer.test.tsx
AiExtractDialog.tsx AiFormulaDialog.tsx AiGenerateDialog.tsx board-view.tsx buttons-panel.tsx calendar-view.tsx cell-editor.tsx cell-renderer.tsx chart-view.tsx database-page-view.tsx database-toolbar.tsx database-view-renderer.tsx db-embedded.tsx filter-builder.tsx form-builder-view.tsx formula-editor.tsx gallery-view.tsx group-builder.tsx list-view.tsx property-editor.tsx record-header.tsx record-panel.tsx sort-builder.tsx table-view.tsx timeline-view.tsx
dialogs/7
__tests__/1move-to-dialog.test.tsx
create-dialog.tsx dialog.tsx file-permissions-dialog.tsx invite-dialog.tsx move-to-dialog.tsx share-dialog.tsx
editor/19
__tests__/3code-block-text.test.ts code-block.test.tsx expandable.test.tsx
block-card.tsx block-renderer.tsx block-save-queue.ts block-toolbar.tsx context-menu.tsx expandable.tsx history-popover.tsx inline-editor.tsx insert-dialog.tsx page-canvas.tsx page-spinner.tsx page-view.tsx selection-toolbar.tsx slash-menu.tsx template-dialog.tsx type-menu.tsx
files/4FileBlock.tsx FileBrowser.tsx FilePicker.tsx PreviewModal.tsx
form/4form-field.tsx form-renderer.tsx form-responses-view.tsx form-view.tsx
layout/8
__tests__/1page-tree-icon.test.tsx
command-palette.tsx factory-logo.tsx page-tree-node.tsx sidebar.tsx theme-provider.tsx topbar.tsx workspace-shell.tsx
mentions/3
__tests__/1mention-model.test.ts
mention-model.ts MentionPopup.tsx
search/3ask-ai-search.tsx results-list.tsx search-dialog.tsx
settings/10
api-keys/4ApiKeyCreateDialog.tsx ApiKeyRevealDialog.tsx ApiKeyTable.tsx ScopesBadge.tsx
webhooks/5CurlExamples.tsx DeliveryInspector.tsx EventExplorer.tsx OverviewTab.tsx WebhooksTab.tsx
settings-shell.tsx
ui/3confirm-dialog.tsx form.tsx toaster.tsx
hooks/21
__tests__/4realtime-frame-handler.test.ts use-blocks.test.ts use-database-options.test.ts use-workspace.test.tsx
realtime-frame-handler.ts use-ai-stream.ts use-auth.ts use-blocks.ts use-database.ts use-db-ai-stream.ts use-forms.ts use-members.ts use-mention-sources.ts use-notifications.ts use-offline-status.ts use-pages.ts use-presence-announcer.ts use-presence-toasts.ts use-presence.ts use-realtime.ts use-workspace.ts
lib/50
__tests__/21ai-database.test.ts ai-generate.test.ts ai-sse.test.ts ai-writing.test.ts api-keys.test.ts api-members.test.ts auth-refresh.test.ts auth-store.test.ts batch-queue-drop.test.ts block-content.test.ts clipboard-import.test.ts code-highlight.test.ts offline-status.test.ts presence-announcer.test.ts presence-color.test.ts presence-cursor.test.ts presence-emitter.test.ts realtime-url.test.ts search.test.ts utils.test.ts writing-diff.test.ts
files/7
__tests__/2picker.test.ts upload.test.ts
api.ts picker.ts upload-store.ts upload.ts use-file-uploads.ts
ai-database.ts ai.ts api-keys.ts api.ts auth-store.ts batch-queue.ts block-content.ts clipboard-import.ts code-highlight.ts constants.ts offline-status.ts org-admin.ts presence-announcer.ts presence-color.ts presence-cursor.ts presence-editor-views.ts presence-emitter.ts query-client.ts realtime.ts search.ts types.ts utils.ts
stores/8
__tests__/1block-patch-store.test.ts
block-patch-store.ts comment-pill-store.ts database-store.ts editor-store.ts offlineQueue.ts presence-store.ts ui-store.ts
instrumentation.ts middleware.ts
.env.example Dockerfile next-env.d.ts next.config.mjs package.json postcss.config.mjs README.md tailwind.config.ts tsconfig.json tsconfig.tsbuildinfo vitest.config.ts
worker/92
src/87
__tests__/13ai-embedding.test.ts ai-request.test.ts ai-usage-budget.test.ts audit-export.test.ts email-decision.test.ts encrypt.test.ts health-search-index-lag.test.ts mime.test.ts retention.test.ts s3-sign.test.ts search-index.test.ts smtp.test.ts webhook-sign.test.ts
health/1server.ts
lib/12
__tests__/1clamav.test.ts
clamav.ts encrypt.ts job-otel.ts mime.ts notify.ts prisma.ts redis-stream.ts redis.ts s3.ts sharp.ts smtp.ts
processors/56
automations/28
__tests__/3condition-evaluator.spec.ts executors.spec.ts run-pipeline.spec.ts
executors/17assign-user.ts call-integration.ts change-status.ts classify.ts create-notification.ts create-page.ts email-cap.ts helpers.ts integration-registry.ts permission.ts record-write.ts registry.ts run-ai-action.ts send-email.ts send-webhook.ts types.ts update-record.ts
condition-evaluator.ts dead-letter.ts log.ts matcher.ts pipeline.ts run-state.ts schedule.ts watchdog.ts
files/15
__tests__/4errors.test.ts scan-gate.test.ts status.test.ts temp.test.ts
steps/4finalize.ts metadata.ts scan.ts thumbnails.ts
cleanup.ts errors.ts generateThumbs.ts processor.ts status.ts temp.ts types.ts
search-index/4builders.ts grants.ts index.ts processor.ts
ai-embedding.ts ai.processor.ts audit-export.ts automations.processor.ts email-decision.processor.ts email.processor.ts maintenance.processor.ts retention.ts webhooks.processor.ts
config.ts events.ts index.ts logger.ts queues.ts
.env.example Dockerfile package.json README.md tsconfig.json
docs/73
api/17ai.md authentication.md automations.md blocks.md comments.md databases.md files.md forms.md openapi.yaml org-admin.md pages.md scim.md search.md sso.md versioning.md webhooks.md workspaces.md
architecture/10ai-architecture.md automation.md backend-architecture.md database-architecture.md frontend-architecture.md realtime-architecture.md search-architecture.md security-architecture.md storage.md system-architecture.md
database/2database-architecture.md redis-keys.md
development/8admin-guide.md coding-standards.md deployment.md development-guide.md files-pipeline.md perf-report.md performance.md testing.md
product/4functional-requirements.md non-functional-requirements.md product-requirements.md ux-requirements.md
requirements/1requirements-matrix.md
security/5audit-report.md files.md forms.md license-allowlist.md security-architecture.md
tasks/16phase-00-foundation.md phase-01-authentication.md phase-02-workspace.md phase-03-pages.md phase-04-editor.md phase-05-databases.md phase-06-relations.md phase-07-collaboration.md phase-08-search.md phase-09-files.md phase-10-forms.md phase-11-automation.md phase-12-ai.md phase-13-api.md phase-14-enterprise.md phase-15-production.md
ui/9ai.md automations.md design-system.md developers.md files.md forms.md org-admin.md scim.md sso.md
support-matrix.md
infrastructure/71
caddy/1Caddyfile
docker/6
init/101-extensions.sql
opensearch/1init-indices.json
.env.docker docker-compose.yml opensearch-init.sh README.md
kubernetes/40
observability/20
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1sync-sources.mjs
alertmanager.yaml grafana.yaml kustomization.yaml namespace.yaml node-exporter.yaml otel-collector.yaml prometheus.yaml
postgres/7configmap.yaml cronjob-backup.yaml cronjob-opensearch-snapshot.yaml pgbouncer.yaml replica.yaml statefulset.yaml walg-iam-policy.yaml
api.yaml configmap.yaml ingress.yaml kustomization.yaml namespace.yaml networkpolicy.yaml pdb.yaml README.md realtime.yaml redis.yaml secret.example.yaml web.yaml worker.yaml
nginx/2nginx.conf README.md
observability/14
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1dashboard-completeness.mjs
README.md
terraform/8compute.tf data.tf main.tf network.tf outputs.tf README.md security.tf variables.tf
packages/289
ai/24
src/20
providers/7anthropic.ts google.ts http.ts index.ts openai-compatible.ts rag.test.ts writing.test.ts
schemas/2database.ts generate.ts
adapters.test.ts config.test.ts config.ts gateway.test.ts gateway.ts index.ts tools.test.ts tools.ts types.ts usage.test.ts usage.ts
package.json README.md tsconfig.json vitest.config.ts
auth/11
src/7index.ts passwords.test.ts passwords.ts sessions.test.ts sessions.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
collaboration/17
src/13awareness.ts codec.test.ts codec.ts index.ts lww.test.ts lww.ts mapping.ts offline.ts outbox.test.ts outbox.ts provider.test.ts provider.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
config/7
src/4eslint.base.js index.ts prettier.base.json tsconfig.base.json
package.json README.md tsconfig.json
database-client/5
src/2index.test.ts index.ts
package.json README.md tsconfig.json
database-engine/33
src/29
filter/2filter.test.ts index.ts
formula/8evaluator.ts formula.test.ts functions.ts index.ts lexer.ts parser.ts types.ts validate.ts
query/6group.ts index.ts paginate.ts pivot.ts query.test.ts sort.ts
relations/2index.ts relations.test.ts
rollups/2index.ts rollups.test.ts
values/5dates.ts index.ts registry.ts types.ts values.test.ts
views/3buttons.ts index.ts views.test.ts
index.ts
package.json README.md tsconfig.json vitest.config.ts
editor/25
src/21
marks/1highlight.ts
nodes/2equation.ts mention.ts
autosave.test.ts autosave.ts clipboard.test.ts clipboard.ts codec.test.ts codec.ts context-menu.test.ts context-menu.ts diff.test.ts diff.ts factory.ts history.test.ts history.ts index.ts positions.ts slash.test.ts slash.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
observability/20
src/16
__tests__/8db-redis-metrics.test.ts metrics.test.ts otel.test.ts pino-otel.test.ts prometheus.test.ts resource.test.ts scrubber.test.ts traceparent.test.ts
index.ts metrics.ts otel.ts pino-otel.ts prometheus.ts resource.ts scrubber.ts traceparent.ts
package.json README.md tsconfig.json vitest.config.ts
permissions/11
src/7index.ts levels.test.ts levels.ts resolve.test.ts resolve.ts rules.test.ts rules.ts
package.json README.md tsconfig.json vitest.config.ts
search/22
src/18chunking.test.ts chunking.ts client.test.ts client.ts embeddings.test.ts embeddings.ts hybrid.test.ts hybrid.ts index.ts indexer.test.ts indexer.ts mapping.test.ts mapping.ts queries.test.ts queries.ts rrf.test.ts rrf.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
shared/61
src/57
dto/17ai.dto.ts auth.dto.test.ts auth.dto.ts block.dto.ts browser-safety.test.ts comment.dto.ts database.dto.test.ts database.dto.ts file.dto.ts formula-ast.ts index.ts notification.dto.ts page.dto.ts search.dto.ts share.dto.ts webhook.dto.ts workspace.dto.ts
events/8audit-catalog.test.ts audit-catalog.ts audit-csv.test.ts audit-csv.ts audit-scope.test.ts audit-scope.ts catalog.test.ts catalog.ts
realtime/6comment-events.ts frames.test.ts frames.ts notification-policy.ts notification-prefs.ts task-0094.test.ts
automation.test.ts automation.ts constants.ts crypto.test.ts crypto.ts env.ts errors.ts events.test.ts events.ts forms.test.ts forms.ts index.ts markdown.test.ts markdown.ts mentions.test.ts mentions.ts net.test.ts net.ts positions.test.ts positions.ts presence.test.ts presence.ts security-headers.ts webhook-delivery.test.ts webhook-delivery.ts webhook-events.ts
package.json README.md tsconfig.json vitest.config.ts
storage/16
src/12client.test.ts client.ts index.ts keys.test.ts keys.ts mime.test.ts mime.ts presign.test.ts presign.ts thumbnails.test.ts thumbnails.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
ui/37
src/33
components/19avatar.tsx badge.tsx button.tsx checkbox.tsx command.tsx context-menu.tsx dialog.tsx dropdown-menu.tsx empty-state.tsx index.ts popover.tsx separator.tsx skeleton.tsx slider.tsx spinner.tsx switch.tsx tabs.tsx toast.tsx tooltip.tsx
color.ts command-filter.test.ts command-filter.ts focus.test.ts focus.ts formatters.test.ts formatters.ts index.ts theme.test.ts theme.ts toast-store.test.ts toast-store.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
prisma/16
migrations/13
20260927000000_init/1migration.sql
20260928000000_search_documents_content_hash/1migration.sql
20260929000000_workspace_usage_storage_bytes/1migration.sql
20260930000000_form_submitted_notification_type/1migration.sql
20261001000000_ai_phase12/1migration.sql
20261002000000_webhooks_secret_encrypted_dlq/1migration.sql
20261002111436_webhook_delivery_latency/1migration.sql
20261002120000_org_admin_2fa_retention_unique/1migration.sql
20261002215124_audit_export/1migration.sql
20261003210000_perf_indexes_hot_paths/1migration.sql
20261004004500_perf_indexes_hot_paths_2/1migration.sql
migration_lock.toml README.md
schema.prisma seed.ts verify.ts
scripts/7backup-health.ts docs-completeness.mjs generate-openapi.mjs minio-init.ts opensearch-reindex.ts storage-orphans.ts verify-clean-clone.sh
tests/169
e2e/63
.auth/1user.json
fixtures/7
files/6clip.mp4 clip.wav clip.webm hero-1400.png note.docx note.txt
realtime.ts
mocks/2oidc-issuer.ts saml-idp.ts
playwright-report/3
data/2a6557bae9c2714a658968d4eb8534195c08ebda8.png e3092d07d00a16063e46385c02a559a9054fa847.webm
index.html
reports/1files-security.json
specs/34ai-database.spec.ts ai-generate.spec.ts ai-rag.spec.ts ai-security.spec.ts ai-writing.spec.ts ai.spec.ts api-keys.spec.ts appearance-theme.spec.ts auth.spec.ts automation.spec.ts collab-two-tab.spec.ts comments.spec.ts databases.spec.ts default-workspace-alias.spec.ts developers.spec.ts editor.spec.ts files.spec.ts final-flow.spec.ts forms.spec.ts oauth.spec.ts org-admin.spec.ts pages.spec.ts performance.spec.ts presence-cursors.spec.ts qa-paste-toggle.spec.ts realtime.spec.ts scim.spec.ts search-index-lag.spec.ts search.spec.ts sharing.spec.ts sso.spec.ts task-0094-comments-notifications.spec.ts webhooks.spec.ts workspace.spec.ts
test-results-final-flow/3
final-flow-Release-v1-0-fi-6c06b-w-state-carries-sequential--chromium-desktop/2test-failed-1.png trace.zip
.last-run.json
utils/4api.ts dnd.ts search-lag.ts wait.ts
.gitignore fixtures.ts global-setup.ts package.json playwright.config.ts playwright.final-flow.config.ts README.md tsconfig.json
fixtures/1
files/1make-sparse.ts
integration/60
ai-capability/2ai-app.capability.test.ts gateway.capability.test.ts
drill/2seed-drill.mjs verify-restore.mjs
fixtures/5
files/5clip.mp4 clip.wav doc.pdf hero-1400.png note.txt
helpers/3api.ts containers.ts seed.ts
realtime/11control-channel.test.ts convergence.test.ts flush-rebase-http.test.ts handshake.test.ts heartbeat-limits.test.ts lifecycle.test.ts presence.test.ts rebase.test.ts resume.test.ts rt-helpers.ts task-0094-fanout.test.ts
ai-chat.integration.test.ts ai-database.integration.test.ts ai-generate.integration.test.ts ai-rag.integration.test.ts ai-writing.integration.test.ts api-keys.integration.test.ts audit-completeness.integration.test.ts audit-export-s3.integration.test.ts auth.integration.test.ts automation-pipeline.integration.test.ts automations.integration.test.ts blocks.integration.test.ts cache-invalidation.integration.test.ts comments.integration.test.ts databases.integration.test.ts files-lifecycle.integration.test.ts files-pipeline.integration.test.ts files-security.integration.test.ts files.integration.test.ts forms-responses.integration.test.ts forms.integration.test.ts oauth.integration.test.ts openapi.contract.test.ts org-admin.integration.test.ts package.json pages.integration.test.ts permissions.integration.test.ts README.md replica-routing.integration.test.ts retention-engine.integration.test.ts scim.integration.test.ts search.integration.test.ts sso.integration.test.ts tsconfig.json vitest.config.ts webhooks.integration.test.ts workspace.integration.test.ts
performance/28
fixtures/1search-100k.ts
helpers/2seed-100k.mjs seed-1k-load.mjs
reports/7api-load-smoke.json database-load-smoke.json editor-local.json realtime-load.json search-full.json search-last.json search-reduced.json
api-load.js api.js archive.tar database-load.js database.js editor-local.mjs extract-100k-env.mjs package.json README.md realtime-1k.js realtime-load.js realtime.js run-realtime-load.sh search-search.k6.js search-summary.json seed-100k.mjs threshold-breach-demo.js verify-zero-op-loss.mjs
security/16
helpers/1app.ts
audit-report.test.ts auth-paths.test.ts deps.test.ts formula-fuzz.test.ts gitleaks-config.test.ts headers.test.ts images.test.ts oracle.ts package.json permission-fuzz.test.ts rate-limits.test.ts README.md tsconfig.json upload-abuse.test.ts vitest.config.ts
README.md
.dockerignore .env .env.bak-task0125 .env.example .eslintrc.json .gitignore .npmrc .prettierignore .prettierrc.json AGENTS.md ARCHITECTURE.md CHANGELOG.md CONTRIBUTING.md gitleaks.toml LICENSE package.json pnpm-lock.yaml pnpm-workspace.yaml PRODUCT-SPEC.md README.md ROADMAP.md SECURITY.md tsconfig.json turbo.json
search-index.test.tsapps/worker/src/__tests__/search-index.test.tsraw← __tests__36,833 B← Back to the app
1/**
2 * TASK-0096 — search indexer unit tests (Docker-free).
3 *
4 * Fakes: a recording PrismaClient, a recording/fetch-backed SearchClient, and
5 * a mocked `enqueue` (the processor's permission.changed → resync path).
6 * The StreamConsumer transport itself is integration-covered (Docker-gated).
7 */
8import { describe, expect, it, vi, beforeEach } from "vitest";
9import type { PrismaClient } from "@prisma/client";
10import { SearchClient, chunkText, DEFAULT_CHUNK_OPTIONS, type SearchDoc } from "@factory/search";
11import { ApiError } from "@factory/shared/errors";
12import type { WorkerEnv } from "../config.js";
13import {
14  buildGrantSnapshot,
15  workspaceFloorSnapshot,
16  pageScopes,
17  type GrantContext
18} from "../processors/search-index/grants.js";
19import {
20  buildPageDoc,
21  buildBlockDocs,
22  buildUserDoc,
23  contentHash as builderHash,
24  upsertMirror,
25  deleteMirror,
26  mirrorRefId,
27  blockText,
28  renderPropertyValue,
29  expandChunkDocs,
30  CHUNK_BODY_THRESHOLD
31} from "../processors/search-index/builders.js";
32import {
33  handleSearchEvent,
34  removeDoc,
35  runReconcile,
36  runReindex,
37  ensureContentIndex,
38  recordSearchDlq,
39  indexDocs,
40  type SearchIndexOps
41} from "../processors/search-index/processor.js";
42 
43vi.mock("../queues.js", () => {
44  const calls: { queue: string; data: Record<string, unknown> }[] = [];
45  return {
46    QUEUES: {
47      files: "files",
48      searchIndex: "search-index",
49      webhooks: "webhooks",
50      automations: "automations",
51      email: "email",
52      ai: "ai",
53      maintenance: "maintenance"
54    },
55    enqueue: vi.fn(async (_c: unknown, queue: string, data: Record<string, unknown>) => {
56      calls.push({ queue, data });
57      return { id: "job-1" };
58    }),
59    enqueueWithId: vi.fn(async () => ({ id: "job-2" })),
60    __calls: calls
61  };
62});
63import { enqueue, QUEUES } from "../queues.js";
64 
65// ---------------------------------------------------------------------------
66// fakes
67// ---------------------------------------------------------------------------
68 
69const ENV = {
70  SEARCH_VECTOR_DIMENSION: 1536,
71  SEARCH_KNN_M: 16,
72  SEARCH_KNN_EF_CONSTRUCTION: 100,
73  WORKER_CONCURRENCY: 2
74} as unknown as WorkerEnv;
75 
76const NOW = new Date("2026-01-02T03:04:05.000Z");
77 
78interface FakePrisma {
79  prisma: PrismaClient;
80  page: { findUnique: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> };
81  searchDocument: {
82    findMany: ReturnType<typeof vi.fn>;
83    upsert: ReturnType<typeof vi.fn>;
84    deleteMany: ReturnType<typeof vi.fn>;
85  };
86  permission: { findMany: ReturnType<typeof vi.fn> };
87  user: { findUnique: ReturnType<typeof vi.fn> };
88  workspace: { findMany: ReturnType<typeof vi.fn> };
89}
90 
91function fakePrisma(): FakePrisma {
92  const page = {
93    findUnique: vi.fn(async () => null),
94    findMany: vi.fn(async () => [])
95  };
96  const searchDocument = {
97    findMany: vi.fn(async () => []),
98    upsert: vi.fn(async (a: { create: Record<string, unknown> }) => ({ id: "sd-1", ...a.create })),
99    deleteMany: vi.fn(async () => ({ count: 1 }))
100  };
101  const permission = { findMany: vi.fn(async () => []) };
102  const user = { findUnique: vi.fn(async () => null) };
103  const workspace = { findMany: vi.fn(async () => []) };
104  const prisma = {
105    page,
106    block: { findUnique: vi.fn(async () => null) },
107    database: { findUnique: vi.fn(async () => null), findMany: vi.fn(async () => []) },
108    databaseRecord: { findUnique: vi.fn(async () => null), findMany: vi.fn(async () => []) },
109    comment: { findUnique: vi.fn(async () => null), findMany: vi.fn(async () => []) },
110    file: { findUnique: vi.fn(async () => null), findMany: vi.fn(async () => []) },
111    user,
112    share: { findMany: vi.fn(async () => []) },
113    permission,
114    searchDocument,
115    workspace,
116    workspaceMember: { findMany: vi.fn(async () => []) }
117  };
118  return {
119    prisma: prisma as unknown as PrismaClient,
120    page,
121    searchDocument,
122    permission,
123    user,
124    workspace
125  };
126}
127 
128interface FakeSearch {
129  ops: SearchIndexOps;
130  indexed: SearchDoc[][];
131  deleted: string[];
132  updated: { docId: string; doc: Record<string, unknown> }[];
133  xadd: ReturnType<typeof vi.fn>;
134  docExists: ReturnType<typeof vi.fn>;
135  deleteChunksOf: ReturnType<typeof vi.fn>;
136}
137 
138function fakeSearch(prisma: PrismaClient): FakeSearch {
139  const indexed: SearchDoc[][] = [];
140  const deleted: string[] = [];
141  const updated: { docId: string; doc: Record<string, unknown> }[] = [];
142  const xadd = vi.fn(async () => "1-1");
143  const docExists = vi.fn(async (ids: string[]) => new Set(ids)); // all present by default
144  const deleteChunksOf = vi.fn(async () => 0); // default: no stale chunks
145  const search = {
146    indexDocs: vi.fn(async (docs: SearchDoc[]) => {
147      indexed.push(docs);
148      return { indexed: docs.length };
149    }),
150    deleteDoc: vi.fn(async (id: string) => {
151      deleted.push(id);
152    }),
153    deleteChunksOf,
154    updateDoc: vi.fn(async (docId: string, doc: Record<string, unknown>) => {
155      updated.push({ docId, doc });
156    }),
157    docExists,
158    indexExists: vi.fn(async () => false),
159    // TASK-0100 S5 self-heal: ensureContentIndex probes whether the bare
160    // CONTENT_INDEX_ALIAS name is a real index (not an alias) and deletes it.
161    isAlias: vi.fn(async () => true),
162    deleteIndex: vi.fn(async () => {}),
163    createIndexIfMissing: vi.fn(async () => true),
164    createAlias: vi.fn(async () => {}),
165    getVectorDimension: vi.fn(async () => null),
166    refresh: vi.fn(async () => {})
167  } as unknown as SearchClient;
168  const ops: SearchIndexOps = {
169    search,
170    prisma,
171    connection: { xadd } as unknown as SearchIndexOps["connection"],
172    env: ENV
173  };
174  return { ops, indexed, deleted, updated, xadd, docExists, deleteChunksOf };
175}
176 
177function allIndexed(fs: FakeSearch): SearchDoc[] {
178  return fs.indexed.flat();
179}
180 
181const basePage = () => ({
182  id: "pg1",
183  workspaceId: "ws1",
184  teamId: null,
185  title: "Roadmap",
186  icon: null,
187  description: "The plan",
188  isPrivate: false,
189  archivedAt: null,
190  trashedAt: null,
191  deletedAt: null,
192  createdBy: "u1",
193  createdAt: NOW,
194  updatedAt: NOW,
195  blocks: [
196    {
197      id: "b1",
198      type: "PARAGRAPH",
199      content: { text: "Ship search" },
200      properties: {},
201      position: "a0",
202      deletedAt: null,
203      createdBy: "u1",
204      createdAt: NOW,
205      updatedAt: NOW
206    }
207  ]
208});
209 
210beforeEach(() => {
211  vi.clearAllMocks();
212});
213 
214// ---------------------------------------------------------------------------
215// grants.ts — the security core
216// ---------------------------------------------------------------------------
217 
218describe("buildGrantSnapshot (mirrors effectiveLevel ≥ VIEW)", () => {
219  const ctx: GrantContext = {
220    workspaceId: "ws1",
221    teamId: null,
222    scopes: pageScopes("ws1", "pg1", null),
223    isPrivate: false,
224    shares: []
225  };
226 
227  it("public page: workspace-visible, no public", async () => {
228    const prisma = fakePrisma();
229    const snap = await buildGrantSnapshot(prisma.prisma as never, ctx, NOW);
230    expect(snap).toEqual({ public: false, workspace: true, user: false, teams: [], users: [] });
231  });
232 
233  it("private page: workspace-invisible unless an explicit user grant applies", async () => {
234    const prisma = fakePrisma();
235    prisma.permission.findMany.mockResolvedValue([
236      { granteeType: "user", granteeId: "u9", resourceType: "page", resourceId: "pg1", level: "VIEW" },
237      // scope NOT applicable to this page → ignored
238      { granteeType: "user", granteeId: "u7", resourceType: "page", resourceId: "other", level: "EDIT" },
239      // granteeType "share" is never counted (guard filters user|team)
240      { granteeType: "share", granteeId: "s1", resourceType: "page", resourceId: "pg1", level: "VIEW" }
241    ]);
242    const snap = await buildGrantSnapshot(prisma.prisma as never, { ...ctx, isPrivate: true }, NOW);
243    expect(snap.workspace).toBe(false); // intended model: private ⇒ workspace-invisible
244    expect(snap.users).toEqual(["u9"]);
245    expect(snap.user).toBe(true);
246  });
247 
248  it("private page with NO explicit grants: the CREATOR is still a viewer (TASK-0100 S5)", async () => {
249    // The owner has no Permission row (ownership lives in Page.createdBy), so
250    // without creatorId the snapshot was users:[] and the creator's own
251    // private page was unfindable in search.
252    const prisma = fakePrisma();
253    prisma.permission.findMany.mockResolvedValue([]);
254    const snap = await buildGrantSnapshot(
255      prisma.prisma as never,
256      { ...ctx, isPrivate: true, creatorId: "owner1" },
257      NOW
258    );
259    expect(snap.workspace).toBe(false);
260    expect(snap.users).toEqual(["owner1"]);
261    expect(snap.user).toBe(true);
262  });
263 
264  it("active public share (allowSearchIndexing, unexpired) ⇒ public", async () => {
265    const prisma = fakePrisma();
266    const snap = await buildGrantSnapshot(
267      prisma.prisma as never,
268      { ...ctx, shares: [{ scope: "PUBLIC", allowSearchIndexing: true, expiresAt: new Date("2030-01-01") }] },
269      NOW
270    );
271    expect(snap.public).toBe(true);
272  });
273 
274  it("expired / non-indexable public share ⇒ not public", async () => {
275    const prisma = fakePrisma();
276    const snap = await buildGrantSnapshot(
277      prisma.prisma as never,
278      {
279        ...ctx,
280        shares: [
281          { scope: "PUBLIC", allowSearchIndexing: true, expiresAt: new Date("2020-01-01") },
282          { scope: "PUBLIC", allowSearchIndexing: false, expiresAt: null }
283        ]
284      },
285      NOW
286    );
287    expect(snap.public).toBe(false);
288  });
289 
290  it("workspaceFloorSnapshot: workspace-visible unless private", () => {
291    expect(workspaceFloorSnapshot({ isPrivate: false, shares: [] }).workspace).toBe(true);
292    expect(workspaceFloorSnapshot({ isPrivate: true, shares: [] }).workspace).toBe(false);
293  });
294});
295 
296// ---------------------------------------------------------------------------
297// builders.ts — doc shape
298// ---------------------------------------------------------------------------
299 
300describe("buildPageDoc / buildBlockDocs", () => {
301  it("composes title + description + block text, grants, url, docId", async () => {
302    const { prisma, page } = fakePrisma();
303    page.findUnique.mockResolvedValue(basePage());
304    const deps = { prisma, now: () => NOW };
305    const doc = await buildPageDoc(deps, "pg1");
306    expect(doc).not.toBeNull();
307    expect(doc!.docId).toBe("page:pg1");
308    expect(doc!.title).toBe("Roadmap");
309    expect(doc!.body).toBe("Roadmap\nThe plan\nShip search");
310    expect(doc!.grants.workspace).toBe(true);
311    expect(doc!.archived).toBe(false);
312    expect(doc!.url).toBe("/w/ws1/page/pg1");
313    expect(doc!.workspaceId).toBe("ws1");
314 
315    const blocks = await buildBlockDocs(deps, "pg1");
316    expect(blocks).toHaveLength(1);
317    expect(blocks[0]!.docId).toBe("block:b1");
318    expect(blocks[0]!.body).toBe("Ship search");
319    expect(blocks[0]!.pageId).toBe("pg1");
320    expect(blocks[0]!.blockId).toBe("b1");
321  });
322 
323  it("trashed page ⇒ null (removed from the index; arch §1.1 / TASK-0096 §5)", async () => {
324    const { prisma, page } = fakePrisma();
325    page.findUnique.mockResolvedValue({ ...basePage(), trashedAt: NOW });
326    const doc = await buildPageDoc({ prisma, now: () => NOW }, "pg1");
327    expect(doc).toBeNull();
328    const blocks = await buildBlockDocs({ prisma, now: () => NOW }, "pg1");
329    expect(blocks).toEqual([]);
330  });
331 
332  it("purged page (deletedAt) ⇒ null (caller deletes)", async () => {
333    const { prisma, page } = fakePrisma();
334    page.findUnique.mockResolvedValue({ ...basePage(), deletedAt: NOW });
335    await expect(buildPageDoc({ prisma, now: () => NOW }, "pg1")).resolves.toBeNull();
336    await expect(buildBlockDocs({ prisma, now: () => NOW }, "pg1")).resolves.toEqual([]);
337  });
338 
339  it("blocks with no text are not separate docs", async () => {
340    const { prisma, page } = fakePrisma();
341    page.findUnique.mockResolvedValue({
342      ...basePage(),
343      blocks: [{ ...basePage().blocks[0]!, content: {} }, { ...basePage().blocks[0]!, id: "b2", content: { text: "x" } }]
344    });
345    const blocks = await buildBlockDocs({ prisma, now: () => NOW }, "pg1");
346    expect(blocks.map((b) => b.id)).toEqual(["b2"]);
347  });
348});
349 
350describe("chunk emission (TASK-0099 §3 — page/record bodies over threshold)", () => {
351  // A paragraph-based body (NOT uniform long text): merged pieces stay
352  // sub-maxChars, so the chunker applies its full `overlapChars` tail copy to
353  // every consecutive chunk (the hard-split budget case would drop the tail).
354  const para = "lorem ipsum dolor sit amet consectetur adipiscing ".repeat(9).trim();
355  const LONG = Array.from({ length: 18 }, (_, i) => `Section ${i}: ${para}`).join("\n\n"); // ~5.2k chars
356  const bigPage = () => ({ ...basePage(), blocks: [{ ...basePage().blocks[0]!, content: { text: LONG } }] });
357 
358  it("a long page body ⇒ 1 whole doc + N chunk docs (N ≥ 5, overlapping)", async () => {
359    const { prisma, page } = fakePrisma();
360    page.findUnique.mockResolvedValue(bigPage());
361    const whole = (await buildPageDoc({ prisma, now: () => NOW }, "pg1"))!;
362    expect(whole.body.length).toBeGreaterThan(CHUNK_BODY_THRESHOLD);
363    expect(whole.body.length).toBeGreaterThan(5000);
364 
365    const expanded = expandChunkDocs(whole);
366    // whole doc first, then one chunk doc per chunker chunk
367    const expectedChunks = chunkText(whole.body, DEFAULT_CHUNK_OPTIONS);
368    expect(expectedChunks.length).toBeGreaterThanOrEqual(5);
369    expect(expanded).toHaveLength(expectedChunks.length + 1);
370    expect(expanded[0]).toBe(whole); // whole doc unchanged (keyword stays whole-doc)
371 
372    for (let i = 0; i < expectedChunks.length; i++) {
373      const c = expanded[i + 1]!;
374      expect(c.type).toBe("chunk");
375      expect(c.docId).toBe(`page:pg1#c${i}`);
376      expect(c.id).toBe("pg1"); // parent's ref id
377      expect(c.chunkId).toBe(`c${i}`);
378      expect(c.chunkIndex).toBe(i);
379      expect(c.parentDocId).toBe("page:pg1");
380      expect(c.body).toBe(expectedChunks[i]!.text);
381      // same grants/facet snapshot as the parent
382      expect(c.grants).toEqual(whole.grants);
383      expect(c.workspaceId).toBe(whole.workspaceId);
384      expect(c.url).toBe(whole.url);
385    }
386 
387    // Consecutive chunks overlap (so matches near boundaries are not lost):
388    // each chunk OPENS with a copy of the previous chunk's `overlapChars` tail.
389    // NOTE: the chunker's `charStart`/`charEnd` are CONTIGUOUS source offsets
390    // (no source-overlap), so overlap is asserted on the chunk TEXT — the
391    // brief's `charStart[i+1] < charEnd[i]` check does not hold for this
392    // chunker (recorded reconciliation).
393    const chunkDocs = expanded.slice(1);
394    const overlap = DEFAULT_CHUNK_OPTIONS.overlapChars;
395    for (let i = 0; i < chunkDocs.length - 1; i++) {
396      const tail = chunkDocs[i]!.body.slice(-overlap);
397      expect(chunkDocs[i + 1]!.body.startsWith(tail)).toBe(true);
398    }
399  });
400 
401  it("a short body ⇒ NO chunk docs (whole doc only)", async () => {
402    const whole = {
403      docId: "page:pg1",
404      type: "page" as const,
405      id: "pg1",
406      workspaceId: "ws1",
407      title: "T",
408      body: "x".repeat(200),
409      grants: { public: false, workspace: true, user: false, teams: [], users: [] },
410      archived: false,
411      url: "/w/ws1/page/pg1",
412      createdAt: "t",
413      updatedAt: "t"
414    };
415    expect(expandChunkDocs(whole)).toEqual([whole]); // exactly one, no chunks
416  });
417 
418  it("non-chunkable types (block/comment/…) ⇒ unchanged, single doc", async () => {
419    const doc = {
420      docId: "comment:c1",
421      type: "comment" as const,
422      id: "c1",
423      workspaceId: "ws1",
424      title: "C",
425      body: "y".repeat(5000), // over threshold but NOT a page/record
426      grants: { public: false, workspace: true, user: false, teams: [], users: [] },
427      archived: false,
428      url: "/w/ws1",
429      createdAt: "t",
430      updatedAt: "t"
431    };
432    expect(expandChunkDocs(doc)).toEqual([doc]);
433  });
434 
435  it("indexDocs writes chunk docs to the index but NOT to the mirror", async () => {
436    const { prisma, page, searchDocument } = fakePrisma();
437    page.findUnique.mockResolvedValue(bigPage());
438    const whole = (await buildPageDoc({ prisma, now: () => NOW }, "pg1"))!;
439    const fs = fakeSearch(prisma);
440 
441    await indexDocs(fs.ops, [whole]);
442    const indexed = allIndexed(fs);
443    expect(indexed.filter((d) => d.type === "chunk").length).toBeGreaterThan(0);
444    expect(indexed.filter((d) => d.type === "chunk").every((d) => d.parentDocId === "page:pg1")).toBe(true);
445    // the whole doc IS mirrored; the chunk docs are NOT (mirror rows = 1, the page)
446    expect(searchDocument.upsert).toHaveBeenCalledTimes(1);
447    const mirrored = searchDocument.upsert.mock.calls[0]![0] as { create: { type: string; refId: string } };
448    expect(mirrored.create.type).toBe("page");
449    expect(mirrored.create.refId).toBe("pg1");
450  });
451 
452  it("indexDocs clears the parent's stale chunks before writing (shrink/grow safe)", async () => {
453    const { prisma, page } = fakePrisma();
454    page.findUnique.mockResolvedValue(bigPage());
455    const whole = (await buildPageDoc({ prisma, now: () => NOW }, "pg1"))!;
456    const fs = fakeSearch(prisma);
457    await indexDocs(fs.ops, [whole]);
458    expect(fs.deleteChunksOf).toHaveBeenCalledTimes(1);
459    expect(fs.deleteChunksOf).toHaveBeenCalledWith(["page:pg1"]);
460  });
461 
462  it("TASK-0125: indexDocs enqueues embed:chunks for page/record only — and survives a failed enqueue", async () => {
463    const { prisma, page } = fakePrisma();
464    page.findUnique.mockResolvedValue(bigPage());
465    const whole = (await buildPageDoc({ prisma, now: () => NOW }, "pg1"))!;
466    const blockDoc = {
467      docId: "block:b1",
468      type: "block" as const,
469      id: "b1",
470      workspaceId: "ws1",
471      title: "b",
472      body: "x",
473      grants: { public: false, workspace: true, user: false, teams: [], users: [] },
474      archived: false,
475      url: "/w/ws1",
476      createdAt: "t",
477      updatedAt: "t"
478    };
479    const enqueueEmbed = vi.fn(async () => {});
480    const fs = fakeSearch(prisma);
481    fs.ops.enqueueEmbed = enqueueEmbed;
482    await indexDocs(fs.ops, [whole, blockDoc]);
483    // the page → one embed job (refId parsed from the docId); the block → none
484    expect(enqueueEmbed).toHaveBeenCalledTimes(1);
485    expect(enqueueEmbed).toHaveBeenCalledWith({ workspaceId: "ws1", docType: "page", refId: "pg1" });
486 
487    // a failed enqueue must NOT fail the index write (nightly backstop)
488    const fs2 = fakeSearch(prisma);
489    fs2.ops.enqueueEmbed = vi.fn(async () => {
490      throw new Error("redis down");
491    });
492    await expect(indexDocs(fs2.ops, [whole])).resolves.toMatchObject({ indexed: expect.any(Number) });
493  });
494 
495  it("a record body over threshold is chunked too (parent-driven, #c<n>)", async () => {
496    const whole = {
497      docId: "record:r1",
498      type: "record" as const,
499      id: "r1",
500      workspaceId: "ws1",
501      title: "R",
502      body: LONG,
503      grants: { public: false, workspace: true, user: false, teams: [], users: [] },
504      archived: false,
505      url: "/w/ws1/databases/d1/r1",
506      createdAt: "t",
507      updatedAt: "t"
508    };
509    const expanded = expandChunkDocs(whole);
510    expect(expanded.length).toBe(chunkText(LONG, DEFAULT_CHUNK_OPTIONS).length + 1);
511    expect(expanded[1]!.type).toBe("chunk");
512    expect(expanded[1]!.docId).toBe("record:r1#c0");
513    expect(expanded[1]!.parentDocId).toBe("record:r1");
514  });
515});
516 
517describe("buildUserDoc (per-workspace doc)", () => {
518  it("stamps docId user:<ws>:<uid> and grants every workspace member", async () => {
519    const { prisma, user } = fakePrisma();
520    user.findUnique.mockResolvedValue({
521      id: "u1",
522      name: "Ada",
523      displayName: "Ada L.",
524      email: "ada@x.io",
525      status: "ACTIVE",
526      updatedAt: NOW
527    });
528    const doc = await buildUserDoc({ prisma, now: () => NOW }, "u1", "ws1");
529    expect(doc!.docId).toBe("user:ws1:u1");
530    expect(doc!.id).toBe("u1");
531    expect(doc!.title).toBe("Ada L.");
532    expect(doc!.grants.workspace).toBe(true);
533    expect(doc!.url).toBe("/w/ws1");
534    expect(mirrorRefId(doc!)).toBe("ws1:u1");
535  });
536 
537  it("suspended account ⇒ null", async () => {
538    const { prisma, user } = fakePrisma();
539    user.findUnique.mockResolvedValue({ id: "u1", name: "Ada", displayName: null, email: "a@x.io", status: "SUSPENDED", updatedAt: NOW });
540    await expect(buildUserDoc({ prisma, now: () => NOW }, "u1", "ws1")).resolves.toBeNull();
541  });
542});
543 
544describe("contentHash / mirrorRefId", () => {
545  const g = { public: false, workspace: true, user: false, teams: [], users: [] };
546  it("is deterministic and sensitive to content/grants", () => {
547    const a = builderHash({ title: "t", body: "b", grants: g, archived: false });
548    const b = builderHash({ title: "t", body: "b", grants: g, archived: false });
549    const c = builderHash({ title: "t", body: "B", grants: g, archived: false });
550    const d = builderHash({ title: "t", body: "b", grants: { ...g, public: true }, archived: false });
551    expect(a).toBe(b);
552    expect(a).not.toBe(c);
553    expect(a).not.toBe(d);
554    expect(a).toMatch(/^[0-9a-f]{40}$/);
555  });
556  it("mirrorRefId: plain id except user (workspace-scoped)", () => {
557    expect(mirrorRefId({ type: "page", id: "p1", workspaceId: "ws1" })).toBe("p1");
558    expect(mirrorRefId({ type: "user", id: "u1", workspaceId: "ws1" })).toBe("ws1:u1");
559    expect(mirrorRefId({ type: "user", id: "u1", workspaceId: undefined })).toBe("u1");
560  });
561});
562 
563describe("blockText / renderPropertyValue", () => {
564  it("blockText: text | parts[] | none", () => {
565    expect(blockText({ text: "hi" })).toBe("hi");
566    expect(blockText({ parts: [{ text: "a" }, { text: "b" }] })).toBe("ab");
567    expect(blockText({})).toBe("");
568    expect(blockText(null)).toBe("");
569  });
570  it("renderPropertyValue: relations → linked titles", () => {
571    const rel = new Map([["r1", "Linked Task"]]);
572    expect(renderPropertyValue("RELATION", ["r1", "r2"], rel)).toBe("Linked Task, r2");
573    expect(renderPropertyValue("TEXT", null)).toBe("");
574    expect(renderPropertyValue("NUMBER", 42)).toBe("42");
575  });
576});
577 
578describe("upsertMirror / deleteMirror", () => {
579  it("upsert stores the grant snapshot + contentHash (create)", async () => {
580    const { prisma, searchDocument } = fakePrisma();
581    const doc: SearchDoc = {
582      docId: "page:pg1",
583      type: "page",
584      id: "pg1",
585      workspaceId: "ws1",
586      title: "Roadmap",
587      body: "b",
588      grants: { public: false, workspace: true, user: false, teams: [], users: [] },
589      archived: false,
590      url: "/w/ws1/page/pg1",
591      createdAt: NOW.toISOString(),
592      updatedAt: NOW.toISOString()
593    };
594    const res = await upsertMirror(prisma as never, doc);
595    expect(res.contentHash).toBe(builderHash(doc));
596    expect(searchDocument.upsert).toHaveBeenCalledWith(
597      expect.objectContaining({
598        where: { type_refId: { type: "page", refId: "pg1" } },
599        create: expect.objectContaining({
600          permissions: doc.grants,
601          contentHash: res.contentHash,
602          metadata: expect.objectContaining({ contentHash: res.contentHash, url: doc.url })
603        })
604      })
605    );
606  });
607 
608  it("deleteMirror uses (type, refId) with workspace-scoped user refId", async () => {
609    const { prisma, searchDocument } = fakePrisma();
610    await deleteMirror(prisma as never, "user", "u1", "ws1");
611    expect(searchDocument.deleteMany).toHaveBeenCalledWith({
612      where: { type: "user", refId: "ws1:u1" }
613    });
614    await deleteMirror(prisma as never, "page", "pg1");
615    expect(searchDocument.deleteMany).toHaveBeenLastCalledWith({
616      where: { type: "page", refId: "pg1" }
617    });
618  });
619});
620 
621// ---------------------------------------------------------------------------
622// processor.ts — event dispatch
623// ---------------------------------------------------------------------------
624 
625describe("handleSearchEvent", () => {
626  it("page.created indexes the page doc + block docs", async () => {
627    const { prisma, page } = fakePrisma();
628    const fs = fakeSearch(prisma);
629    page.findUnique.mockResolvedValue(basePage());
630    await handleSearchEvent(fs.ops, { event: "page.created", payload: { workspaceId: "ws1", pageId: "pg1" } });
631    const docs = allIndexed(fs);
632    expect(docs.map((d) => d.docId).sort()).toEqual(["block:b1", "page:pg1"]);
633    expect(docs.find((d) => d.docId === "page:pg1")!.grants.workspace).toBe(true);
634  });
635 
636  it("page.deleted permanent ⇒ deletes page + mirror-enumerated blocks", async () => {
637    const { prisma, page, searchDocument } = fakePrisma();
638    const fs = fakeSearch(prisma);
639    // source rows are gone (purged) — the mirror still lists the block docs
640    page.findUnique.mockResolvedValue(null);
641    searchDocument.findMany.mockResolvedValue([{ refId: "b1" }, { refId: "b2" }]);
642    await handleSearchEvent(fs.ops, {
643      event: "page.deleted",
644      payload: { workspaceId: "ws1", pageId: "pg1", permanent: true, pageIds: ["pg1", "pg2"] }
645    });
646    expect(fs.deleted).toContain("page:pg1");
647    expect(fs.deleted).toContain("page:pg2");
648    expect(fs.deleted).toContain("block:b1");
649    expect(fs.deleted).toContain("block:b2");
650  });
651 
652  it("page.deleted (trash) removes the doc from the index (restore re-adds via reconcile)", async () => {
653    const { prisma, page, searchDocument } = fakePrisma();
654    const fs = fakeSearch(prisma);
655    // The source row is trashed when the event arrives → no doc to rebuild.
656    page.findUnique.mockResolvedValue({ ...basePage(), trashedAt: NOW });
657    // The mirror still lists the block docs (block source rows survive trash)
658    // — they must be removed too.
659    searchDocument.findMany.mockResolvedValue([{ refId: "b1" }]);
660    await handleSearchEvent(fs.ops, {
661      event: "page.deleted",
662      payload: { workspaceId: "ws1", pageId: "pg1", permanent: false, pageIds: ["pg1"] }
663    });
664    expect(fs.deleted).toContain("page:pg1");
665    expect(fs.deleted).toContain("block:b1");
666    expect(allIndexed(fs)).toEqual([]); // nothing re-upserted
667  });
668 
669  it("permission.changed ⇒ workspace resync enqueued (never targeted)", async () => {
670    const { prisma } = fakePrisma();
671    const fs = fakeSearch(prisma);
672    await handleSearchEvent(fs.ops, {
673      event: "permission.changed",
674      payload: { workspaceId: "ws1", userId: "u2", role: "MEMBER" }
675    });
676    expect(enqueue).toHaveBeenCalledWith(fs.ops.connection, QUEUES.maintenance, {
677      type: "workspace-resync",
678      workspaceId: "ws1"
679    });
680    expect(fs.indexed).toEqual([]); // no direct doc write
681  });
682 
683  it("comment.deleted ⇒ doc removed", async () => {
684    const { prisma, searchDocument } = fakePrisma();
685    const fs = fakeSearch(prisma);
686    searchDocument.deleteMany.mockResolvedValue({ count: 1 });
687    await handleSearchEvent(fs.ops, {
688      event: "comment.deleted",
689      payload: { workspaceId: "ws1", commentId: "c1" }
690    });
691    expect(fs.deleted).toEqual(["comment:c1"]);
692    expect(searchDocument.deleteMany).toHaveBeenCalled();
693  });
694 
695  it("user.register stamps the per-workspace user doc", async () => {
696    const { prisma, user } = fakePrisma();
697    const fs = fakeSearch(prisma);
698    user.findUnique.mockResolvedValue({
699      id: "u1",
700      name: "Ada",
701      displayName: null,
702      email: "ada@x.io",
703      status: "ACTIVE",
704      updatedAt: NOW
705    });
706    await handleSearchEvent(fs.ops, {
707      event: "user.register",
708      payload: { userId: "u1", workspaceId: "ws1" }
709    });
710    expect(allIndexed(fs).map((d) => d.docId)).toEqual(["user:ws1:u1"]);
711  });
712 
713  it("unknown source row (poison) ⇒ logs, does not throw", async () => {
714    const { prisma } = fakePrisma();
715    const fs = fakeSearch(prisma);
716    await expect(
717      handleSearchEvent(fs.ops, { event: "database.created", payload: { workspaceId: "ws1", databaseId: "gone" } })
718    ).resolves.toBeUndefined();
719    expect(fs.indexed).toEqual([]);
720  });
721 
722  it("transport failure ⇒ rethrows (entry stays pending)", async () => {
723    const { prisma, page } = fakePrisma();
724    const fs = fakeSearch(prisma);
725    page.findUnique.mockResolvedValue(basePage());
726    fs.ops.search.indexDocs = vi.fn(async () => {
727      throw new Error("opensearch down");
728    }) as never;
729    await expect(
730      handleSearchEvent(fs.ops, { event: "page.updated", payload: { pageId: "pg1" } })
731    ).rejects.toThrow("opensearch down");
732  });
733 
734  it("event not in the search subscription ⇒ no-op", async () => {
735    const { prisma } = fakePrisma();
736    const fs = fakeSearch(prisma);
737    await handleSearchEvent(fs.ops, { event: "notification.created", payload: { id: "n1" } });
738    expect(fs.indexed).toEqual([]);
739    expect(fs.deleted).toEqual([]);
740  });
741});
742 
743describe("removeDoc", () => {
744  it("tolerates 404 (idempotent delete)", async () => {
745    const { prisma } = fakePrisma();
746    const fs = fakeSearch(prisma);
747    fs.ops.search.deleteDoc = vi.fn(async () => {
748      const { ApiError, ErrorCodes } = await import("@factory/shared/errors");
749      throw new ApiError(ErrorCodes.SEARCH_UNAVAILABLE, { message: "404", status: 404 });
750    }) as never;
751    await expect(removeDoc(fs.ops, "page", "pg1", "ws1")).resolves.toBeUndefined();
752  });
753});
754 
755// ---------------------------------------------------------------------------
756// reconcile + index management
757// ---------------------------------------------------------------------------
758 
759describe("runReconcile (contentHash sweep)", () => {
760  it("repairs drifted docs and removes orphan mirror rows", async () => {
761    const { prisma, page, searchDocument } = fakePrisma();
762    const fs = fakeSearch(prisma);
763    // one in-sync row, one drifted, one orphan (source gone)
764    const inSyncDoc = await (async () => {
765      page.findUnique.mockResolvedValueOnce(basePage());
766      return buildPageDoc({ prisma, now: () => NOW }, "pg1");
767    })();
768    page.findUnique.mockReset();
769    page.findUnique.mockImplementation(async (a: { where: { id: string } }) =>
770      a.where.id === "pg1" ? basePage() : a.where.id === "pg2" ? { ...basePage(), id: "pg2", title: "Changed" } : null
771    );
772    const inSyncHash = builderHash(inSyncDoc!);
773    searchDocument.findMany.mockResolvedValue([
774      { type: "page", refId: "pg1", contentHash: inSyncHash, indexedAt: NOW },
775      { type: "page", refId: "pg2", contentHash: "stale", indexedAt: NOW },
776      { type: "page", refId: "pg3", contentHash: "stale", indexedAt: NOW }
777    ]);
778    const res = await runReconcile(fs.ops, "ws1");
779    // pg1 in sync (skipped), pg2 repaired (rebuilt from the fake), pg3 removed
780    expect(res.checked).toBe(3);
781    expect(res.repaired).toBe(1);
782    expect(res.removed).toBe(1);
783    expect(fs.deleted).toContain("page:pg3");
784  });
785 
786  it("re-indexes a doc LOST from the index (healthy mirror, missing in OpenSearch)", async () => {
787    const { prisma, page, searchDocument } = fakePrisma();
788    const fs = fakeSearch(prisma);
789    page.findUnique.mockResolvedValue(basePage());
790    // Mirror says the doc is current, but the index lost it (deleted directly).
791    const hash = builderHash((await buildPageDoc({ prisma, now: () => NOW }, "pg1"))!);
792    searchDocument.findMany.mockResolvedValue([
793      { type: "page", refId: "pg1", contentHash: hash, indexedAt: NOW }
794    ]);
795    // docExists reports page:pg1 MISSING.
796    fs.docExists.mockImplementation(async (ids: string[]) => new Set(ids.filter((id) => id !== "page:pg1")));
797    const res = await runReconcile(fs.ops, "ws1");
798    expect(res.repaired).toBe(1);
799    expect(allIndexed(fs).map((d) => d.docId)).toContain("page:pg1");
800  });
801 
802  it("re-adds a RESTORED page via the source→index direction (no mirror row)", async () => {
803    const { prisma, page, searchDocument } = fakePrisma();
804    const fs = fakeSearch(prisma);
805    // The trash dropped the mirror row; the RESTORE published no domain event,
806    // so only the source table has the page again (trashedAt null).
807    searchDocument.findMany.mockResolvedValue([]);
808    page.findMany.mockResolvedValue([{ id: "pg1" }]);
809    page.findUnique.mockResolvedValue(basePage()); // trashedAt null
810    // The index lost it (docExists says not present).
811    fs.docExists.mockResolvedValue(new Set());
812    const res = await runReconcile(fs.ops, "ws1");
813    // Page + its block doc both re-added from the source tables.
814    expect(res.repaired).toBe(2);
815    expect(allIndexed(fs).map((d) => d.docId).sort()).toEqual(["block:b1", "page:pg1"]);
816  });
817});
818 
819describe("runReindex (full (re)index, T-156 org scoping)", () => {
820  it("no orgId → reindexes ALL workspaces", async () => {
821    const { prisma, workspace } = fakePrisma();
822    const fs = fakeSearch(prisma);
823    workspace.findMany.mockResolvedValue([
824      { id: "ws1" },
825      { id: "ws2" }
826    ]);
827    const res = await runReindex(fs.ops);
828    expect(workspace.findMany).toHaveBeenCalledWith({
829      where: {},
830      select: { id: true }
831    });
832    expect(res.workspaces).toBe(2);
833  });
834 
835  it("orgId → ONLY that org's workspaces are reindexed (other orgs untouched)", async () => {
836    const { prisma, workspace } = fakePrisma();
837    const fs = fakeSearch(prisma);
838    workspace.findMany.mockResolvedValue([
839      { id: "ws1" },
840      { id: "ws2" }
841    ]);
842    const res = await runReindex(fs.ops, undefined, "org1");
843    expect(workspace.findMany).toHaveBeenCalledWith({
844      where: { organizationId: "org1" },
845      select: { id: true }
846    });
847    // The where clause is the scoping guarantee: a second org's workspaces
848    // (ws3) would be filtered out before any doc is collected.
849    workspace.findMany.mockResolvedValue([{ id: "ws3" }]);
850    await runReindex(fs.ops, undefined, "org2");
851    expect(workspace.findMany).toHaveBeenLastCalledWith({
852      where: { organizationId: "org2" },
853      select: { id: true }
854    });
855    // and an org with NO workspaces reindexes nothing (the loop is a no-op)
856    workspace.findMany.mockResolvedValue([]);
857    const empty = await runReindex(fs.ops, undefined, "org-empty");
858    expect(empty.workspaces).toBe(0);
859    expect(empty.docs).toBe(0);
860  });
861});
862 
863describe("ensureContentIndex", () => {
864  it("creates the index + alias when missing (real SearchClient, fake fetch)", async () => {
865    const calls: { path: string; method: string }[] = [];
866    const fetchImpl = (vi.fn(async (url: string | URL | Request) => {
867      const u = String(url);
868      const method = u.includes("_bulk") ? "POST" : u.endsWith("_aliases") ? "POST" : u.includes("factory-content") ? "PUT" : "GET";
869      calls.push({ path: u.replace("http://os:9200", ""), method });
870      if (u.endsWith("/factory-content-v1")) {
871        // first GET: exists?
872        if (calls.filter((c) => c.path === "/factory-content-v1").length === 1) {
873          return new Response("nf", { status: 404 });
874        }
875        return new Response(JSON.stringify({ mappings: { properties: { vector: { dimension: 1536 } } } }), { status: 200 });
876      }
877      return new Response(JSON.stringify({}), { status: 200 });
878    }) as unknown) as typeof fetch;
879    const { prisma } = fakePrisma();
880    const search = new SearchClient({ baseUrl: "http://os:9200", fetchImpl });
881    const ops: SearchIndexOps = { search, prisma, connection: { xadd: vi.fn() } as never, env: ENV };
882    const res = await ensureContentIndex(ops);
883    expect(res.created).toBe(true);
884    const paths = calls.map((c) => c.path);
885    expect(paths).toContain("/factory-content-v1"); // PUT create
886    expect(paths).toContain("/_aliases"); // alias swap
887  });
888 
889  it("leaves a dimension-mismatched index alone (rollover warning path)", async () => {
890    const { prisma } = fakePrisma();
891    const search = {
892      indexExists: vi.fn(async () => true),
893      getVectorDimension: vi.fn(async () => 768),
894      createIndexIfMissing: vi.fn(),
895      createAlias: vi.fn()
896    } as unknown as SearchClient;
897    const ops: SearchIndexOps = { search, prisma, connection: { xadd: vi.fn() } as never, env: ENV };
898    const res = await ensureContentIndex(ops);
899    expect(res.created).toBe(false);
900    expect(search.createIndexIfMissing).not.toHaveBeenCalled();
901    expect(search.createAlias).not.toHaveBeenCalled();
902  });
903});
904 
905describe("recordSearchDlq", () => {
906  it("xadds a MAXLEN-capped DLQ entry", async () => {
907    const { prisma } = fakePrisma();
908    const fs = fakeSearch(prisma);
909    const job = { id: "job-9", data: { type: "reindex", workspaceId: "ws1" } } as never;
910    await recordSearchDlq(fs.ops.connection, job, new Error("boom"));
911    expect(fs.xadd).toHaveBeenCalledTimes(1);
912    // xadd(key, "MAXLEN", "~", "1000", "*", "jobId", ..., "error", ...)
913    // The "*" stream id is MANDATORY — omitting it made Redis reject the
914    // command ("ERR Invalid stream ID") and crash the whole worker process.
915    const args = fs.xadd.mock.calls[0]! as unknown as string[];
916    expect(args[0]).toBe("search-index:dlq");
917    expect(args).toContain("MAXLEN");
918    expect(args).toContain("~");
919    expect(args[4]).toBe("*"); // stream id position (after MAXLEN ~ 1000)
920    expect(args).toContain("job-9");
921    expect(args).toContain("boom");
922  });
923});
924