@tangen/db 0.0.0-stage → 0.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +73 -2
  2. package/drizzle/0000_flimsy_shotgun.sql +339 -0
  3. package/drizzle/0001_app_data.sql +11 -0
  4. package/drizzle/0002_add_indexes.sql +36 -0
  5. package/drizzle/0003_extension_data_rename.sql +3 -0
  6. package/drizzle/0004_extension_data_published_globals.sql +12 -0
  7. package/drizzle/0005_auth.sql +51 -0
  8. package/drizzle/0006_auth_identities.sql +13 -0
  9. package/drizzle/0007_ai.sql +18 -0
  10. package/drizzle/0008_ai_chat_threads.sql +5 -0
  11. package/drizzle/0009_forms_source_slug.sql +11 -0
  12. package/drizzle/0010_rbac_roles_status.sql +14 -0
  13. package/drizzle/0011_jobs_v2.sql +67 -0
  14. package/drizzle/0012_search_platform.sql +36 -0
  15. package/drizzle/0013_search_synonym_label.sql +2 -0
  16. package/drizzle/0014_object_types_disabled.sql +2 -0
  17. package/drizzle/0015_playground_collections.sql +25 -0
  18. package/drizzle/0016_catchup_activity_promotion.sql +36 -0
  19. package/drizzle/0017_fields_disabled.sql +3 -0
  20. package/drizzle/0018_object_types_plural.sql +1 -0
  21. package/drizzle/0019_member_profile.sql +2 -0
  22. package/drizzle/0020_paused_jobs.sql +7 -0
  23. package/drizzle/0021_form_submission_notes.sql +2 -0
  24. package/drizzle/0022_paused_jobs_env_reason.sql +4 -0
  25. package/drizzle/0023_scale_indexes.sql +3 -0
  26. package/drizzle/0024_migration_runs.sql +24 -0
  27. package/drizzle/0025_porter_transfer.sql +30 -0
  28. package/drizzle/0026_cron_timezone.sql +1 -0
  29. package/drizzle/0027_rate_limit_counters.sql +6 -0
  30. package/drizzle/0028_realtime_events.sql +10 -0
  31. package/drizzle/0030_delivery_artifacts.sql +10 -0
  32. package/drizzle/0031_realtime_presence.sql +8 -0
  33. package/drizzle/0032_schema_pushes.sql +15 -0
  34. package/drizzle/0033_studio_design_capability.sql +37 -0
  35. package/drizzle/0034_object_types_provided_by.sql +12 -0
  36. package/drizzle/0035_custom_table_owners.sql +24 -0
  37. package/drizzle/0036_revision_version_unique.sql +42 -0
  38. package/drizzle/0037_job_queue_dedupe.sql +20 -0
  39. package/drizzle/0038_search_documents.sql +87 -0
  40. package/drizzle/0039_job_prune_indexes.sql +25 -0
  41. package/drizzle/0040_extension_data_object_idx.sql +10 -0
  42. package/drizzle/0041_knowledge.sql +79 -0
  43. package/drizzle/0042_schema_pushes_checksum.sql +7 -0
  44. package/drizzle/0043_guard_adoption.sql +12 -0
  45. package/drizzle/0044_retention_indexes.sql +7 -0
  46. package/drizzle/0045_extension_install_per_env.sql +40 -0
  47. package/drizzle/0046_extension_migrations.sql +18 -0
  48. package/drizzle/0047_environment_role.sql +25 -0
  49. package/drizzle/0048_environment_role_adoption.sql +30 -0
  50. package/drizzle/0049_release_lifecycle.sql +55 -0
  51. package/drizzle/0050_member_env_membership.sql +21 -0
  52. package/drizzle/0051_remove_environments.sql +350 -0
  53. package/drizzle/0052_release_actor_columns.sql +9 -0
  54. package/drizzle/0053_kernel_event_record.sql +26 -0
  55. package/drizzle/0054_kernel_run_identity.sql +13 -0
  56. package/drizzle/0055_kernel_identities.sql +19 -0
  57. package/drizzle/0056_forms_leave_core.sql +7 -0
  58. package/drizzle/0057_auth_leave_core.sql +8 -0
  59. package/drizzle/0058_member_grants.sql +11 -0
  60. package/drizzle/0059_extension_granted_grants.sql +7 -0
  61. package/drizzle/0060_connections.sql +21 -0
  62. package/drizzle/meta/0000_snapshot.json +1692 -0
  63. package/drizzle/meta/0002_snapshot.json +1935 -0
  64. package/drizzle/meta/0003_snapshot.json +1935 -0
  65. package/drizzle/meta/_journal.json +426 -0
  66. package/package.json +85 -4
  67. package/src/cache-keys.ts +43 -0
  68. package/src/client.ts +136 -0
  69. package/src/content-ddl.ts +70 -0
  70. package/src/content-tables.ts +110 -0
  71. package/src/custom-tables-loader.ts +65 -0
  72. package/src/ensure-system-schema.ts +195 -0
  73. package/src/extension-install-state.ts +220 -0
  74. package/src/extension-migrations.ts +270 -0
  75. package/src/generated/content-tables.ts +271 -0
  76. package/src/generated/custom-tables.ts +18 -0
  77. package/src/index.ts +24 -0
  78. package/src/instrument.ts +139 -0
  79. package/src/knowledge-schema.ts +232 -0
  80. package/src/migrate.ts +67 -0
  81. package/src/prepare-policy.ts +118 -0
  82. package/src/reconcile.ts +115 -0
  83. package/src/schema/ai-chats.ts +36 -0
  84. package/src/schema/ai-settings.ts +24 -0
  85. package/src/schema/api-keys.ts +37 -0
  86. package/src/schema/audit-log.ts +54 -0
  87. package/src/schema/connections.ts +35 -0
  88. package/src/schema/custom-table-owners.ts +32 -0
  89. package/src/schema/delivery-artifacts.ts +33 -0
  90. package/src/schema/entity-tags.ts +33 -0
  91. package/src/schema/extension-data.ts +38 -0
  92. package/src/schema/extension-migrations.ts +42 -0
  93. package/src/schema/fields.ts +37 -0
  94. package/src/schema/globals.ts +35 -0
  95. package/src/schema/identities.ts +27 -0
  96. package/src/schema/index.ts +51 -0
  97. package/src/schema/jobs.ts +104 -0
  98. package/src/schema/knowledge.ts +85 -0
  99. package/src/schema/media.ts +52 -0
  100. package/src/schema/member-grants.ts +34 -0
  101. package/src/schema/members.ts +49 -0
  102. package/src/schema/migration-runs.ts +46 -0
  103. package/src/schema/object-types.ts +41 -0
  104. package/src/schema/objects.ts +38 -0
  105. package/src/schema/paused-jobs.ts +25 -0
  106. package/src/schema/playground.ts +43 -0
  107. package/src/schema/porter-transfer.ts +47 -0
  108. package/src/schema/realtime.ts +45 -0
  109. package/src/schema/releases.ts +95 -0
  110. package/src/schema/roles.ts +27 -0
  111. package/src/schema/saved-views.ts +33 -0
  112. package/src/schema/schema-pushes.ts +43 -0
  113. package/src/schema/search-documents.ts +47 -0
  114. package/src/schema/search-query-log.ts +24 -0
  115. package/src/schema/search-recommendations.ts +27 -0
  116. package/src/schema/search-synonyms.ts +23 -0
  117. package/src/schema/webhook-deliveries.ts +37 -0
  118. package/src/schema/webhooks.ts +36 -0
  119. package/src/schema/workspace-activity.ts +35 -0
  120. package/src/schema/workspace-domains.ts +24 -0
  121. package/src/schema/workspace-installed-apps.ts +48 -0
  122. package/src/schema/workspaces.ts +24 -0
  123. package/src/schema-kit.ts +64 -0
  124. package/src/sealed.ts +84 -0
  125. package/src/seed/skeleton.ts +77 -0
  126. package/src/sql/apply-gin-indexes.ts +31 -0
  127. package/src/sql/gin-indexes.sql +11 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * File Name: instrument.ts
3
+ * Purpose: Query Monitor capture layer - wraps a postgres.js client so every
4
+ * query (drizzle funnels through client.unsafe(); raw call sites use it
5
+ * directly) reports { sql, durationMs } to a hook. Near-zero overhead when
6
+ * no hook is set (one ref read per query).
7
+ *
8
+ * Author: Raghunath J
9
+ * License: MIT
10
+ *
11
+ * Dependencies/Notes: Three load-bearing details, all verified against
12
+ * drizzle-orm 0.45 postgres-js driver + postgres.js 3.4 sources:
13
+ * 1. postgres.js Query is a LAZY Promise subclass - execution starts on the
14
+ * first .then/.execute. Drizzle mutates the query (.values()) before
15
+ * awaiting, so the wrapper must NEVER call .then itself; it patches the
16
+ * instance's handle/resolve/reject (own properties) for timing instead.
17
+ * 2. resolve/reject fire in the connection socket's async context, where
18
+ * AsyncLocalStorage would misattribute. Hence the TWO-PHASE hook: the
19
+ * hook factory runs at unsafe()-call time (inside the caller's async
20
+ * chain, where ALS is correct) and returns a recorder that is invoked
21
+ * later at settle time.
22
+ * 3. begin()/savepoint()/reserve() hand out FRESH scoped Sql instances with
23
+ * their own unsafe closures - they are instrumented recursively or
24
+ * transaction/reserved-connection queries would be invisible.
25
+ */
26
+
27
+ export interface QueryEvent {
28
+ /** Parameterized SQL text ($1 placeholders) - parameter VALUES are never captured. */
29
+ sql: string
30
+ /** Execution time: socket write → settle (excludes pool-queue wait). */
31
+ durationMs: number
32
+ /** Time spent queued waiting for a free pooled connection, when measurable. */
33
+ waitMs?: number
34
+ error?: boolean
35
+ }
36
+
37
+ /**
38
+ * Two-phase hook: called at query-issue time (correct async context); returns
39
+ * the recorder to invoke at settle time, or null to skip this query.
40
+ */
41
+ export type QueryHook = () => ((e: QueryEvent) => void) | null
42
+
43
+ const hookRef: { current: QueryHook | null } = { current: null }
44
+
45
+ /** Install (or clear with null) the process-wide query hook. */
46
+ export function setQueryHook(hook: QueryHook | null): void {
47
+ hookRef.current = hook
48
+ }
49
+
50
+ /** Patch one lazy Query instance for timing without forcing execution. */
51
+ function instrumentQuery(q: any, sqlText: string, record: (e: QueryEvent) => void): void {
52
+ let start = performance.now() // fallback if handle never fires (cached/errored early)
53
+ let execStart: number | null = null
54
+ const origHandle = q.handle
55
+ q.handle = function (this: any, ...args: unknown[]) {
56
+ if (!this.executed) start = performance.now()
57
+ return origHandle.apply(this, args)
58
+ }
59
+ // q.active flips true exactly when a connection makes this query its
60
+ // CURRENT one (verified postgres.js 3.4: connection.js:170 immediately on
61
+ // an idle connection, :573 when the preceding pipelined query finishes;
62
+ // resolve/reject set it back to false - falsy, ignored here). Trapping it
63
+ // separates waiting (pool queue + pipeline) from real execution: with the
64
+ // old creation→settle clock, a burst of concurrent requests counted all
65
+ // that waiting as "db time" (8x inflation observed on a cold admin load).
66
+ let activeVal = q.active
67
+ Object.defineProperty(q, 'active', {
68
+ configurable: true,
69
+ get: () => activeVal,
70
+ set: (v) => {
71
+ if (execStart === null && v) execStart = performance.now()
72
+ activeVal = v
73
+ },
74
+ })
75
+ const origResolve = q.resolve
76
+ const origReject = q.reject
77
+ const settle = (error?: boolean) => {
78
+ const now = performance.now()
79
+ const waitMs = execStart !== null ? Math.max(0, execStart - start) : 0
80
+ record({
81
+ sql: sqlText,
82
+ durationMs: now - (execStart ?? start),
83
+ ...(waitMs >= 0.5 ? { waitMs } : {}),
84
+ ...(error ? { error: true } : {}),
85
+ })
86
+ }
87
+ q.resolve = (v: unknown) => { settle(); return origResolve(v) }
88
+ q.reject = (e: unknown) => { settle(true); return origReject(e) }
89
+ }
90
+
91
+ /**
92
+ * Instruments a postgres.js client (or a scoped tx/reserved instance) in
93
+ * place - identity is preserved, so $rawClient consumers and duck-typed
94
+ * checks ({ unsafe, release }) keep working. Idempotent.
95
+ */
96
+ export function instrumentSql<T extends object>(sql: T): T {
97
+ const s = sql as any
98
+ if (s.__qmInstrumented) return sql
99
+ s.__qmInstrumented = true
100
+
101
+ const origUnsafe = s.unsafe
102
+ if (typeof origUnsafe === 'function') {
103
+ s.unsafe = function (this: any, query: string, args?: unknown, opts?: unknown) {
104
+ const q = origUnsafe.call(this, query, args, opts)
105
+ const record = hookRef.current?.() // two-phase: capture recorder NOW
106
+ if (record) instrumentQuery(q, query, record)
107
+ return q
108
+ }
109
+ }
110
+
111
+ if (typeof s.begin === 'function') {
112
+ const origBegin = s.begin
113
+ s.begin = function (this: any, options: any, fn?: any) {
114
+ const wrap = (f: any) => (txSql: any) => f(instrumentSql(txSql))
115
+ return typeof options === 'function'
116
+ ? origBegin.call(this, wrap(options))
117
+ : origBegin.call(this, options, wrap(fn))
118
+ }
119
+ }
120
+
121
+ if (typeof s.savepoint === 'function') {
122
+ const origSavepoint = s.savepoint
123
+ s.savepoint = function (this: any, name: any, fn?: any) {
124
+ const wrap = (f: any) => (spSql: any) => f(instrumentSql(spSql))
125
+ return typeof name === 'function'
126
+ ? origSavepoint.call(this, wrap(name))
127
+ : origSavepoint.call(this, name, wrap(fn))
128
+ }
129
+ }
130
+
131
+ if (typeof s.reserve === 'function') {
132
+ const origReserve = s.reserve
133
+ s.reserve = async function (this: any) {
134
+ return instrumentSql(await origReserve.call(this))
135
+ }
136
+ }
137
+
138
+ return sql
139
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * File Name: knowledge-schema.ts
3
+ * Purpose: The ONE authority for the Knowledge tables' DDL (freeze review C4).
4
+ * The canonical statements live here; migration 0041 carries them
5
+ * verbatim (a test proves it); `ensureConditionalSystemSchema` applies
6
+ * the pgvector-dependent part from the canonical migration entrypoint
7
+ * when the extension arrived after the migration ran; and
8
+ * `pinKnowledgeVectorSpace` is the single configuration-driven DDL
9
+ * step - typing `embedding` to the configured model's dimensions and
10
+ * building its ANN index - owned here, not by @tangen/knowledge.
11
+ *
12
+ * Author: Raghunath J
13
+ * License: MIT
14
+ *
15
+ * Dependencies/Notes: pgvector is an OPERATOR PREREQUISITE (decision B in
16
+ * docs/architecture/database-safety.md): Tangen never runs
17
+ * `CREATE EXTENSION vector`. `knowledge_meta`, `knowledge_qa` and
18
+ * `knowledge_queries` need nothing and always exist after 0041;
19
+ * `knowledge_chunks` needs the `vector` type and exists once it does.
20
+ * Every statement is idempotent so the migration adopts tables an
21
+ * older @tangen/knowledge created at runtime without touching a row.
22
+ */
23
+
24
+ /** One statement in, rows out; the shape postgres.js `unsafe` and PGlite `query` both give. */
25
+ export type SystemSqlExec = (text: string, params?: unknown[]) => Promise<Array<Record<string, unknown>>>
26
+
27
+ export const KNOWLEDGE_TABLES = ['knowledge_meta', 'knowledge_chunks', 'knowledge_qa', 'knowledge_queries'] as const
28
+ export type KnowledgeTable = typeof KNOWLEDGE_TABLES[number]
29
+
30
+ /** The tables that need nothing but PostgreSQL. Idempotent: adoption of runtime-created tables is a no-op. */
31
+ export const KNOWLEDGE_TABLE_DDL: readonly string[] = [
32
+ `CREATE TABLE IF NOT EXISTS "knowledge_meta" (
33
+ "workspace_id" text NOT NULL,
34
+ "provider" text NOT NULL,
35
+ "model" text NOT NULL,
36
+ "dimensions" integer NOT NULL,
37
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
38
+ PRIMARY KEY ("workspace_id")
39
+ )`,
40
+ `CREATE TABLE IF NOT EXISTS "knowledge_qa" (
41
+ "id" text PRIMARY KEY,
42
+ "workspace_id" text NOT NULL,
43
+ "question" text NOT NULL,
44
+ "answer" text NOT NULL,
45
+ "audience" text NOT NULL DEFAULT 'public',
46
+ "enabled" boolean NOT NULL DEFAULT true,
47
+ "updated_by" text,
48
+ "created_at" timestamptz NOT NULL DEFAULT now(),
49
+ "updated_at" timestamptz NOT NULL DEFAULT now()
50
+ )`,
51
+ `CREATE INDEX IF NOT EXISTS "knowledge_qa_scope_idx" ON "knowledge_qa" ("workspace_id")`,
52
+ `CREATE TABLE IF NOT EXISTS "knowledge_queries" (
53
+ "id" bigserial PRIMARY KEY,
54
+ "workspace_id" text NOT NULL,
55
+ "query" text NOT NULL,
56
+ "source" text NOT NULL,
57
+ "audience" text NOT NULL DEFAULT 'public',
58
+ "locale" text NOT NULL DEFAULT '',
59
+ "top_score" real,
60
+ "hit_count" integer NOT NULL DEFAULT 0,
61
+ "answered" boolean NOT NULL DEFAULT false,
62
+ "created_at" timestamptz NOT NULL DEFAULT now()
63
+ )`,
64
+ `CREATE INDEX IF NOT EXISTS "knowledge_queries_scope_idx" ON "knowledge_queries" ("workspace_id", "created_at")`,
65
+ ]
66
+
67
+ /**
68
+ * The table that needs pgvector's `vector` type. `embedding` is declared
69
+ * without dimensions: they belong to the configured model and are applied by
70
+ * `pinKnowledgeVectorSpace`. Installs whose table an older runtime created
71
+ * already carry `vector(N)`; the pin leaves a matching column alone.
72
+ */
73
+ export const KNOWLEDGE_CHUNKS_DDL: readonly string[] = [
74
+ `CREATE TABLE IF NOT EXISTS "knowledge_chunks" (
75
+ "id" bigserial PRIMARY KEY,
76
+ "workspace_id" text NOT NULL,
77
+ "source_kind" text NOT NULL,
78
+ "source_id" text NOT NULL,
79
+ "source_group" text NOT NULL DEFAULT '',
80
+ "field" text NOT NULL DEFAULT '',
81
+ "locale" text NOT NULL DEFAULT '',
82
+ "audience" text NOT NULL DEFAULT 'public',
83
+ "chunk_index" integer NOT NULL DEFAULT 0,
84
+ "content" text NOT NULL,
85
+ "content_hash" text NOT NULL,
86
+ "metadata" jsonb,
87
+ "embedding" vector NOT NULL,
88
+ "updated_at" timestamptz NOT NULL DEFAULT now()
89
+ )`,
90
+ `ALTER TABLE "knowledge_chunks" ADD COLUMN IF NOT EXISTS "audience" text NOT NULL DEFAULT 'public'`,
91
+ `CREATE INDEX IF NOT EXISTS "knowledge_chunks_scope_idx" ON "knowledge_chunks" ("workspace_id", "source_kind", "source_id")`,
92
+ ]
93
+
94
+ /** The migration's form of the pgvector-dependent DDL: applied only when the `vector` type is installed. */
95
+ export function knowledgeChunksMigrationBlock(): string {
96
+ const body = KNOWLEDGE_CHUNKS_DDL.map((s) => ` EXECUTE $ddl$${s}$ddl$;`).join('\n')
97
+ return `DO $$
98
+ BEGIN
99
+ IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vector') THEN
100
+ ${body}
101
+ END IF;
102
+ END $$;`
103
+ }
104
+
105
+ export const VECTOR_INSTALLED_SQL = `SELECT 1 AS ok FROM pg_type WHERE typname = 'vector'`
106
+
107
+ /** True when pgvector's `vector` type exists in this database. */
108
+ export async function isVectorInstalled(exec: SystemSqlExec): Promise<boolean> {
109
+ return (await exec(VECTOR_INSTALLED_SQL)).length > 0
110
+ }
111
+
112
+ /**
113
+ * The part of the system schema that depends on an optional server extension,
114
+ * applied from the canonical migration entrypoint (`runMigrations`) so an
115
+ * extension installed AFTER migration 0041 ran still yields the table the
116
+ * migration declares. Idempotent; nothing when pgvector is absent.
117
+ */
118
+ export async function ensureConditionalSystemSchema(exec: SystemSqlExec): Promise<{ knowledgeChunks: 'applied' | 'skipped' }> {
119
+ if (!(await isVectorInstalled(exec))) return { knowledgeChunks: 'skipped' }
120
+ for (const stmt of KNOWLEDGE_CHUNKS_DDL) await exec(stmt)
121
+ return { knowledgeChunks: 'applied' }
122
+ }
123
+
124
+ export interface KnowledgeSchemaStatus {
125
+ /** pgvector's `vector` type exists in this database. */
126
+ vectorInstalled: boolean
127
+ /** The server ships pgvector (installable); null when it could not be asked. */
128
+ vectorAvailable: boolean | null
129
+ tables: Record<KnowledgeTable, boolean>
130
+ /** The typed dimensions of `knowledge_chunks.embedding`; null when untyped; undefined when the table is absent. */
131
+ embeddingDimensions: number | null | undefined
132
+ }
133
+
134
+ const EMBEDDING_TYPE_SQL = `SELECT format_type(a.atttypid, a.atttypmod) AS type
135
+ FROM pg_attribute a
136
+ JOIN pg_class c ON c.oid = a.attrelid
137
+ JOIN pg_namespace n ON n.oid = c.relnamespace
138
+ WHERE n.nspname = current_schema() AND c.relname = 'knowledge_chunks' AND a.attname = 'embedding' AND NOT a.attisdropped`
139
+
140
+ function parseVectorType(type: string | undefined): number | null | undefined {
141
+ if (type === undefined) return undefined
142
+ const m = /^vector\((\d+)\)$/.exec(type)
143
+ return m ? Number(m[1]) : null
144
+ }
145
+
146
+ /** What doctor and the diagnostics look at: the extension, the four tables, the typed dimensions. */
147
+ export async function readKnowledgeSchemaStatus(exec: SystemSqlExec): Promise<KnowledgeSchemaStatus> {
148
+ const vectorInstalled = await isVectorInstalled(exec)
149
+ let vectorAvailable: boolean | null = null
150
+ try {
151
+ vectorAvailable = (await exec(`SELECT 1 AS ok FROM pg_available_extensions WHERE name = 'vector'`)).length > 0
152
+ } catch { vectorAvailable = null }
153
+ // No bind parameters: doctor's read-only query takes a bare statement.
154
+ const present = new Set((await exec(
155
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN (${KNOWLEDGE_TABLES.map((t) => `'${t}'`).join(', ')})`,
156
+ )).map((r) => String(r.table_name)))
157
+ const tables = Object.fromEntries(KNOWLEDGE_TABLES.map((t) => [t, present.has(t)])) as Record<KnowledgeTable, boolean>
158
+ const typeRows = tables.knowledge_chunks ? await exec(EMBEDDING_TYPE_SQL) : []
159
+ return { vectorInstalled, vectorAvailable, tables, embeddingDimensions: parseVectorType(typeRows[0]?.type as string | undefined) }
160
+ }
161
+
162
+ /** What an operator has to do, given the status; empty when Knowledge can run. */
163
+ export function knowledgeSchemaAdvice(status: KnowledgeSchemaStatus): string {
164
+ if (!status.vectorInstalled) {
165
+ return status.vectorAvailable === false
166
+ ? 'pgvector is not installed on this PostgreSQL server. It is a prerequisite Tangen does not install: add the package for your platform (docs/knowledge.md, "Enabling pgvector"), run `CREATE EXTENSION vector;` as the database owner, then start Tangen again so its migrations create knowledge_chunks.'
167
+ : 'pgvector is available on this server but not enabled in this database. Run `CREATE EXTENSION vector;` as the database owner, then start Tangen again (or run `bun run db:migrate` in packages/db) so its migrations create knowledge_chunks.'
168
+ }
169
+ const missing = KNOWLEDGE_TABLES.filter((t) => !status.tables[t])
170
+ if (missing.length > 0) {
171
+ return `The knowledge table${missing.length === 1 ? '' : 's'} ${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} missing: Tangen's migrations have not run against this database. They run at API boot (unless TANGEN_SKIP_MIGRATE=1), or run \`bun run db:migrate\` in packages/db.`
172
+ }
173
+ return ''
174
+ }
175
+
176
+ export class KnowledgeSchemaError extends Error {
177
+ readonly status: KnowledgeSchemaStatus
178
+ constructor(status: KnowledgeSchemaStatus, message = knowledgeSchemaAdvice(status)) {
179
+ super(message)
180
+ this.name = 'KnowledgeSchemaError'
181
+ this.status = status
182
+ }
183
+ }
184
+
185
+ export interface VectorSpacePin {
186
+ dimensions: number
187
+ /** The column was (re)typed to these dimensions. */
188
+ retyped: boolean
189
+ /** The ANN index now on the column: built here, already there, or none (sequential scan). */
190
+ index: 'hnsw' | 'ivfflat' | 'existing' | 'none'
191
+ }
192
+
193
+ /**
194
+ * Pins `knowledge_chunks.embedding` to the configured model's dimensions and
195
+ * gives it an ANN index. The one configuration-driven DDL step Knowledge has;
196
+ * owned here so the DDL has exactly one author. Idempotent: a column already
197
+ * typed to `dimensions` is left alone. Refuses to retype a column that holds
198
+ * rows in another space - that is a rebuild, and the caller says so.
199
+ */
200
+ export async function pinKnowledgeVectorSpace(exec: SystemSqlExec, dimensions: number): Promise<VectorSpacePin> {
201
+ const dims = Math.floor(dimensions)
202
+ if (!(dims > 0)) throw new Error(`knowledge: embedding dimensions must be a positive integer, got ${dimensions}`)
203
+ const status = await readKnowledgeSchemaStatus(exec)
204
+ if (!status.tables.knowledge_chunks) throw new KnowledgeSchemaError(status)
205
+
206
+ let retyped = false
207
+ if (status.embeddingDimensions !== dims) {
208
+ if (status.embeddingDimensions !== null && status.embeddingDimensions !== undefined) {
209
+ const n = Number((await exec(`SELECT count(*)::int AS n FROM "knowledge_chunks"`))[0]?.n ?? 0)
210
+ if (n > 0) {
211
+ throw new Error(`knowledge_chunks holds ${n} chunk${n === 1 ? '' : 's'} in a ${status.embeddingDimensions}-dimension vector space and the configured model needs ${dims}. Run: tangen knowledge index --rebuild`)
212
+ }
213
+ }
214
+ await exec(`ALTER TABLE "knowledge_chunks" ALTER COLUMN "embedding" TYPE vector(${dims})`)
215
+ retyped = true
216
+ }
217
+
218
+ const existing = await exec(`SELECT 1 AS ok FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'knowledge_chunks' AND indexname = 'knowledge_chunks_embedding_idx'`)
219
+ if (existing.length > 0) return { dimensions: dims, retyped, index: 'existing' }
220
+ // HNSW when the server has it (pgvector >= 0.5); ivfflat otherwise; a sequential scan still answers at small scale.
221
+ try {
222
+ await exec(`CREATE INDEX IF NOT EXISTS "knowledge_chunks_embedding_idx" ON "knowledge_chunks" USING hnsw ("embedding" vector_cosine_ops)`)
223
+ return { dimensions: dims, retyped, index: 'hnsw' }
224
+ } catch {
225
+ try {
226
+ await exec(`CREATE INDEX IF NOT EXISTS "knowledge_chunks_embedding_idx" ON "knowledge_chunks" USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100)`)
227
+ return { dimensions: dims, retyped, index: 'ivfflat' }
228
+ } catch {
229
+ return { dimensions: dims, retyped, index: 'none' }
230
+ }
231
+ }
232
+ }
package/src/migrate.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * File Name: migrate.ts
3
+ * Purpose: Applies pending Drizzle ORM migrations against a PostgreSQL database,
4
+ * and can be run directly as a CLI script via `bun src/migrate.ts`.
5
+ *
6
+ * Author: Raghunath J
7
+ * License: MIT
8
+ *
9
+ * Dependencies/Notes: Uses a single-connection postgres.js client (max: 1) and
10
+ * holds ONE session advisory lock for the whole run, so N
11
+ * replicas booting together migrate one after another: the
12
+ * first applies, the rest wait and find nothing pending
13
+ * (plan T1.16). Requires DATABASE_URL
14
+ * environment variable when invoked as a script. After the
15
+ * ledgered migrations it applies the conditional system
16
+ * schema (knowledge_chunks once pgvector exists) - see
17
+ * knowledge-schema.ts; this is the ONLY entrypoint for it.
18
+ */
19
+ import postgres from 'postgres'
20
+ import { drizzle } from 'drizzle-orm/postgres-js'
21
+ import { migrate } from 'drizzle-orm/postgres-js/migrator'
22
+ import { join } from 'node:path'
23
+ import { ensureConditionalSystemSchema } from './knowledge-schema'
24
+
25
+ const migrationsFolder = join(import.meta.dir, '..', 'drizzle')
26
+
27
+ /** One key for every Tangen migrator on a database ("tangen" as digits). */
28
+ export const MIGRATION_LOCK_KEY = 7461676531
29
+
30
+ /**
31
+ * Applies all pending Drizzle migrations from the `drizzle/` folder to the target database.
32
+ * @param databaseUrl - PostgreSQL connection string for the target database
33
+ */
34
+ export async function runMigrations(databaseUrl: string): Promise<void> {
35
+ // onnotice: idempotent re-runs emit "already exists, skipping" NOTICEs —
36
+ // expected, so keep them out of stdout.
37
+ const client = postgres(databaseUrl, { max: 1, onnotice: () => {} })
38
+ const db = drizzle(client)
39
+ try {
40
+ // Session-level: released explicitly below, and by the server if this
41
+ // connection dies. Every migrator on this database queues here.
42
+ await client.unsafe(`SELECT pg_advisory_lock(${MIGRATION_LOCK_KEY})`)
43
+ // Migration 0051 (remove environments) keeps one environment per workspace;
44
+ // when that choice is ambiguous the operator names it, and the migration
45
+ // reads the name from this session setting.
46
+ const keep = process.env.TANGEN_KEEP_ENVIRONMENT?.trim()
47
+ if (keep) await client.unsafe(`SET tangen.keep_environment = '${keep.replace(/'/g, "''")}'`)
48
+ await migrate(db, { migrationsFolder })
49
+ // The part of the system schema that waits on an optional server
50
+ // extension: the migration declares it, and this applies it once the
51
+ // extension is there - the same canonical text, from the same entrypoint.
52
+ await ensureConditionalSystemSchema((text, params) => client.unsafe(text, (params ?? []) as never) as unknown as Promise<Array<Record<string, unknown>>>)
53
+ console.log('Migrations applied successfully')
54
+ } finally {
55
+ await client.unsafe(`SELECT pg_advisory_unlock(${MIGRATION_LOCK_KEY})`).catch(() => {})
56
+ await client.end()
57
+ }
58
+ }
59
+
60
+ if (import.meta.main) {
61
+ const url = process.env.DATABASE_URL
62
+ if (!url) {
63
+ console.error('DATABASE_URL is required')
64
+ process.exit(1)
65
+ }
66
+ await runMigrations(url)
67
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * File Name: prepare-policy.ts
3
+ * Purpose: Make `TANGEN_DB_PREPARE=true` mean what it says for ordinary
4
+ * Drizzle queries, which postgres.js otherwise executes unprepared however
5
+ * the connection is configured.
6
+ *
7
+ * Author: Raghunath J
8
+ * License: MIT
9
+ *
10
+ * Dependencies/Notes: This is a compatibility shim, not a feature. postgres.js
11
+ * 3.4 builds every `unsafe()` query with a per-query `prepare: false`:
12
+ *
13
+ * // postgres/src/index.js
14
+ * function unsafe(string, args = [], options = {}) {
15
+ * const query = new Query([string], args, handler, cancel, {
16
+ * prepare: false, ...options, simple: ...
17
+ * })
18
+ *
19
+ * and the connection resolves the two levels with the per-query value
20
+ * winning outright:
21
+ *
22
+ * // postgres/src/connection.js
23
+ * q.prepare = options.prepare && ('prepare' in q.options ? q.options.prepare : true)
24
+ *
25
+ * drizzle-orm 0.45's postgres-js session issues EVERY query as
26
+ * `client.unsafe(sql, params)` with no third argument, so no Drizzle query is
27
+ * ever prepared and each one pays Parse + Describe + Flush before
28
+ * Bind + Execute - two network round trips instead of one. Measured against a
29
+ * cloud database 23 ms away that is 47.7 ms per statement instead of 23.4 ms.
30
+ *
31
+ * Upstream: drizzle-orm issue #6096, open as of 2026-08-28; 1.0.0-rc.4 does
32
+ * not address ordinary-query preparation for this driver.
33
+ *
34
+ * REMOVAL CONDITION: when the installed drizzle-orm passes a preparation
35
+ * option through to `client.unsafe`, delete this file and its call in
36
+ * `createDb`. `prepare-policy.test.ts` fails loudly if that ever happens
37
+ * silently, because it asserts the third argument this shim adds.
38
+ *
39
+ * Three properties are deliberate:
40
+ * 1. It engages ONLY when preparation was explicitly asked for. Unset means
41
+ * today's behaviour, unchanged.
42
+ * 2. It cannot force preparation on. postgres.js ANDs the connection-level
43
+ * flag first, so `prepare: false` on the pool stays genuinely unprepared
44
+ * even with this wrapper installed. Proven by test.
45
+ * 3. It touches only parameterized queries. BEGIN/COMMIT/ROLLBACK carry no
46
+ * parameters, take the simple-query path, and are left byte-identical.
47
+ */
48
+
49
+ /** Marks a client this policy has already wrapped; wrapping twice is a no-op. */
50
+ const APPLIED = '__tangenPreparePolicy'
51
+
52
+ /** postgres.js `unsafe(string, args?, options?)` in its three calling shapes. */
53
+ type UnsafeFn = (query: string, args?: unknown, opts?: unknown) => unknown
54
+
55
+ /**
56
+ * Threads an explicit preparation policy through a postgres.js client, and
57
+ * through the scoped clients `begin`/`savepoint`/`reserve` hand out, so
58
+ * transaction-scoped Drizzle queries follow the same policy as ordinary ones.
59
+ *
60
+ * Composes with instrumentSql in either order: both wrap `unsafe` and both
61
+ * recurse into the scoped clients, so Query Monitor capture is unaffected.
62
+ *
63
+ * @param sql - A postgres.js client, or a transaction/reserved scope of one.
64
+ * @param prepare - True to ask for prepared execution. Anything else returns
65
+ * the client untouched, so the unprepared path keeps its exact behaviour.
66
+ * @returns The same client instance, mutated in place (identity preserved).
67
+ */
68
+ export function applyPreparePolicy<T extends object>(sql: T, prepare: boolean): T {
69
+ if (!prepare) return sql
70
+ const s = sql as unknown as Record<string, unknown>
71
+ if (s[APPLIED]) return sql
72
+ s[APPLIED] = true
73
+
74
+ const origUnsafe = s.unsafe as UnsafeFn | undefined
75
+ if (typeof origUnsafe === 'function') {
76
+ s.unsafe = function (this: unknown, query: string, args?: unknown, opts?: unknown) {
77
+ // `unsafe(string, options)` is a supported two-argument shape: a
78
+ // non-array second argument IS the options object. Passing a third
79
+ // argument would stop postgres.js collapsing it, so collapse it here.
80
+ const twoArgOptions = args !== undefined && !Array.isArray(args)
81
+ const params = twoArgOptions ? [] : ((args as unknown[] | undefined) ?? [])
82
+ const given = (twoArgOptions ? args : opts) as Record<string, unknown> | undefined
83
+
84
+ // Only parameterized statements are affected. A statement with no
85
+ // parameters takes the simple-query path, which is already one round
86
+ // trip and which transaction control depends on.
87
+ if (params.length === 0) return origUnsafe.call(this, query, params, given ?? {})
88
+
89
+ // An explicit per-call option always wins: this fills a gap, it does not
90
+ // overrule a caller who has decided.
91
+ const merged = given && 'prepare' in given ? given : { ...given, prepare: true }
92
+ return origUnsafe.call(this, query, params, merged)
93
+ } as UnsafeFn
94
+ }
95
+
96
+ // The scoped clients begin/savepoint/reserve hand out are fresh instances
97
+ // with their own unsafe closures. Without recursion, every statement inside
98
+ // a transaction would keep the old behaviour - which is most of a write.
99
+ for (const key of ['begin', 'savepoint'] as const) {
100
+ const orig = s[key] as ((a: unknown, b?: unknown) => unknown) | undefined
101
+ if (typeof orig !== 'function') continue
102
+ s[key] = function (this: unknown, first: unknown, second?: unknown) {
103
+ const wrap = (f: (scoped: object) => unknown) => (scoped: object) => f(applyPreparePolicy(scoped, true))
104
+ return typeof first === 'function'
105
+ ? orig.call(this, wrap(first as (scoped: object) => unknown))
106
+ : orig.call(this, first, wrap(second as (scoped: object) => unknown))
107
+ }
108
+ }
109
+
110
+ const origReserve = s.reserve as (() => Promise<object>) | undefined
111
+ if (typeof origReserve === 'function') {
112
+ s.reserve = async function (this: unknown) {
113
+ return applyPreparePolicy(await origReserve.call(this), true)
114
+ }
115
+ }
116
+
117
+ return sql
118
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * File Name: reconcile.ts
3
+ * Purpose: Safety latch for drizzle migration-ledger drift — records migrations
4
+ * whose SQL is ALREADY applied to the database but was never written to
5
+ * `drizzle.__drizzle_migrations` (e.g. schema applied via `push` or an
6
+ * out-of-band `ALTER` before the migrator ran). Pure bookkeeping: it
7
+ * never runs migration SQL, it only inserts the missing ledger rows with
8
+ * the correct sha256 hash + journal timestamp.
9
+ *
10
+ * Author: Raghunath J
11
+ * License: MIT
12
+ *
13
+ * Dependencies/Notes: SAFETY — only reconcile when the schema is genuinely up to
14
+ * date (tables/columns exist from push or a partial migrate). It marks
15
+ * migrations as applied WITHOUT running them, so on a truly-behind DB it
16
+ * would skip real schema changes. Dry-run by default; `apply` writes.
17
+ */
18
+ import { createHash } from 'node:crypto'
19
+ import { readFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import postgres from 'postgres'
22
+
23
+ const migrationsFolder = join(import.meta.dir, '..', 'drizzle')
24
+
25
+ export interface JournalMigration { idx: number; tag: string; when: number; hash: string }
26
+
27
+ /**
28
+ * Read the drizzle journal and compute each migration's sha256 (the exact value
29
+ * the __drizzle_migrations table stores — a hash of the raw .sql file content).
30
+ */
31
+ export function readJournalMigrations(folder = migrationsFolder): JournalMigration[] {
32
+ const journal = JSON.parse(readFileSync(join(folder, 'meta', '_journal.json'), 'utf8')) as {
33
+ entries: { idx: number; tag: string; when: number }[]
34
+ }
35
+ return journal.entries.map((e) => ({
36
+ idx: e.idx,
37
+ tag: e.tag,
38
+ when: e.when,
39
+ hash: createHash('sha256').update(readFileSync(join(folder, `${e.tag}.sql`), 'utf8')).digest('hex'),
40
+ }))
41
+ }
42
+
43
+ /** Pure: journal migrations not present in the recorded-hash set, in journal order. */
44
+ export function computeMissingMigrations(
45
+ journal: JournalMigration[],
46
+ recordedHashes: ReadonlySet<string>,
47
+ ): JournalMigration[] {
48
+ return journal.filter((m) => !recordedHashes.has(m.hash))
49
+ }
50
+
51
+ export interface ReconcileResult {
52
+ /** True when the ledger table exists; false means the DB was never migrated. */
53
+ ledgerExists: boolean
54
+ recorded: number
55
+ total: number
56
+ missing: JournalMigration[]
57
+ applied: boolean
58
+ }
59
+
60
+ /**
61
+ * Reconcile the migration ledger with the journal. Dry-run by default (reports
62
+ * what's missing); pass `{ apply: true }` to insert the missing rows.
63
+ */
64
+ export async function reconcileMigrations(
65
+ databaseUrl: string,
66
+ opts: { apply?: boolean } = {},
67
+ ): Promise<ReconcileResult> {
68
+ const journal = readJournalMigrations()
69
+ const sql = postgres(databaseUrl, { max: 1 })
70
+ try {
71
+ const exists = await sql.unsafe(
72
+ `SELECT to_regclass('drizzle.__drizzle_migrations') IS NOT NULL AS present`,
73
+ )
74
+ if (!exists[0]?.present) {
75
+ return { ledgerExists: false, recorded: 0, total: journal.length, missing: [], applied: false }
76
+ }
77
+ const rows = await sql.unsafe(`SELECT hash FROM drizzle.__drizzle_migrations`)
78
+ const recorded = new Set(rows.map((r) => (r as unknown as { hash: string }).hash))
79
+ const missing = computeMissingMigrations(journal, recorded)
80
+ if (opts.apply) {
81
+ for (const m of missing) {
82
+ await sql.unsafe(
83
+ `INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`,
84
+ [m.hash, m.when],
85
+ )
86
+ }
87
+ }
88
+ return { ledgerExists: true, recorded: recorded.size, total: journal.length, missing, applied: !!opts.apply }
89
+ } finally {
90
+ await sql.end()
91
+ }
92
+ }
93
+
94
+ // CLI: `bun src/reconcile.ts [--apply]`. Dry-run reports drift; --apply records it.
95
+ if (import.meta.main) {
96
+ const url = process.env.DATABASE_URL
97
+ if (!url) {
98
+ console.error('DATABASE_URL is required')
99
+ process.exit(1)
100
+ }
101
+ const apply = process.argv.includes('--apply')
102
+ const res = await reconcileMigrations(url, { apply })
103
+ if (!res.ledgerExists) {
104
+ console.log('No migration ledger found - this DB has never been migrated. Run `db:migrate` first.')
105
+ } else if (res.missing.length === 0) {
106
+ console.log(`Migration ledger is in sync (${res.recorded}/${res.total} recorded). Nothing to reconcile.`)
107
+ } else if (apply) {
108
+ console.log(`Recorded ${res.missing.length} previously-applied migration(s): ${res.missing.map((m) => m.tag).join(', ')}`)
109
+ console.log(`Ledger now in sync (${res.total}/${res.total}).`)
110
+ } else {
111
+ console.log(`Ledger drift: ${res.recorded}/${res.total} recorded. Missing (already applied, not recorded):`)
112
+ for (const m of res.missing) console.log(` - ${m.tag}`)
113
+ console.log('\nRun with --apply to record them (only do this if the schema is actually up to date).')
114
+ }
115
+ }