@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.
- package/README.md +73 -2
- package/drizzle/0000_flimsy_shotgun.sql +339 -0
- package/drizzle/0001_app_data.sql +11 -0
- package/drizzle/0002_add_indexes.sql +36 -0
- package/drizzle/0003_extension_data_rename.sql +3 -0
- package/drizzle/0004_extension_data_published_globals.sql +12 -0
- package/drizzle/0005_auth.sql +51 -0
- package/drizzle/0006_auth_identities.sql +13 -0
- package/drizzle/0007_ai.sql +18 -0
- package/drizzle/0008_ai_chat_threads.sql +5 -0
- package/drizzle/0009_forms_source_slug.sql +11 -0
- package/drizzle/0010_rbac_roles_status.sql +14 -0
- package/drizzle/0011_jobs_v2.sql +67 -0
- package/drizzle/0012_search_platform.sql +36 -0
- package/drizzle/0013_search_synonym_label.sql +2 -0
- package/drizzle/0014_object_types_disabled.sql +2 -0
- package/drizzle/0015_playground_collections.sql +25 -0
- package/drizzle/0016_catchup_activity_promotion.sql +36 -0
- package/drizzle/0017_fields_disabled.sql +3 -0
- package/drizzle/0018_object_types_plural.sql +1 -0
- package/drizzle/0019_member_profile.sql +2 -0
- package/drizzle/0020_paused_jobs.sql +7 -0
- package/drizzle/0021_form_submission_notes.sql +2 -0
- package/drizzle/0022_paused_jobs_env_reason.sql +4 -0
- package/drizzle/0023_scale_indexes.sql +3 -0
- package/drizzle/0024_migration_runs.sql +24 -0
- package/drizzle/0025_porter_transfer.sql +30 -0
- package/drizzle/0026_cron_timezone.sql +1 -0
- package/drizzle/0027_rate_limit_counters.sql +6 -0
- package/drizzle/0028_realtime_events.sql +10 -0
- package/drizzle/0030_delivery_artifacts.sql +10 -0
- package/drizzle/0031_realtime_presence.sql +8 -0
- package/drizzle/0032_schema_pushes.sql +15 -0
- package/drizzle/0033_studio_design_capability.sql +37 -0
- package/drizzle/0034_object_types_provided_by.sql +12 -0
- package/drizzle/0035_custom_table_owners.sql +24 -0
- package/drizzle/0036_revision_version_unique.sql +42 -0
- package/drizzle/0037_job_queue_dedupe.sql +20 -0
- package/drizzle/0038_search_documents.sql +87 -0
- package/drizzle/0039_job_prune_indexes.sql +25 -0
- package/drizzle/0040_extension_data_object_idx.sql +10 -0
- package/drizzle/0041_knowledge.sql +79 -0
- package/drizzle/0042_schema_pushes_checksum.sql +7 -0
- package/drizzle/0043_guard_adoption.sql +12 -0
- package/drizzle/0044_retention_indexes.sql +7 -0
- package/drizzle/0045_extension_install_per_env.sql +40 -0
- package/drizzle/0046_extension_migrations.sql +18 -0
- package/drizzle/0047_environment_role.sql +25 -0
- package/drizzle/0048_environment_role_adoption.sql +30 -0
- package/drizzle/0049_release_lifecycle.sql +55 -0
- package/drizzle/0050_member_env_membership.sql +21 -0
- package/drizzle/0051_remove_environments.sql +350 -0
- package/drizzle/0052_release_actor_columns.sql +9 -0
- package/drizzle/0053_kernel_event_record.sql +26 -0
- package/drizzle/0054_kernel_run_identity.sql +13 -0
- package/drizzle/0055_kernel_identities.sql +19 -0
- package/drizzle/0056_forms_leave_core.sql +7 -0
- package/drizzle/0057_auth_leave_core.sql +8 -0
- package/drizzle/0058_member_grants.sql +11 -0
- package/drizzle/0059_extension_granted_grants.sql +7 -0
- package/drizzle/0060_connections.sql +21 -0
- package/drizzle/meta/0000_snapshot.json +1692 -0
- package/drizzle/meta/0002_snapshot.json +1935 -0
- package/drizzle/meta/0003_snapshot.json +1935 -0
- package/drizzle/meta/_journal.json +426 -0
- package/package.json +85 -4
- package/src/cache-keys.ts +43 -0
- package/src/client.ts +136 -0
- package/src/content-ddl.ts +70 -0
- package/src/content-tables.ts +110 -0
- package/src/custom-tables-loader.ts +65 -0
- package/src/ensure-system-schema.ts +195 -0
- package/src/extension-install-state.ts +220 -0
- package/src/extension-migrations.ts +270 -0
- package/src/generated/content-tables.ts +271 -0
- package/src/generated/custom-tables.ts +18 -0
- package/src/index.ts +24 -0
- package/src/instrument.ts +139 -0
- package/src/knowledge-schema.ts +232 -0
- package/src/migrate.ts +67 -0
- package/src/prepare-policy.ts +118 -0
- package/src/reconcile.ts +115 -0
- package/src/schema/ai-chats.ts +36 -0
- package/src/schema/ai-settings.ts +24 -0
- package/src/schema/api-keys.ts +37 -0
- package/src/schema/audit-log.ts +54 -0
- package/src/schema/connections.ts +35 -0
- package/src/schema/custom-table-owners.ts +32 -0
- package/src/schema/delivery-artifacts.ts +33 -0
- package/src/schema/entity-tags.ts +33 -0
- package/src/schema/extension-data.ts +38 -0
- package/src/schema/extension-migrations.ts +42 -0
- package/src/schema/fields.ts +37 -0
- package/src/schema/globals.ts +35 -0
- package/src/schema/identities.ts +27 -0
- package/src/schema/index.ts +51 -0
- package/src/schema/jobs.ts +104 -0
- package/src/schema/knowledge.ts +85 -0
- package/src/schema/media.ts +52 -0
- package/src/schema/member-grants.ts +34 -0
- package/src/schema/members.ts +49 -0
- package/src/schema/migration-runs.ts +46 -0
- package/src/schema/object-types.ts +41 -0
- package/src/schema/objects.ts +38 -0
- package/src/schema/paused-jobs.ts +25 -0
- package/src/schema/playground.ts +43 -0
- package/src/schema/porter-transfer.ts +47 -0
- package/src/schema/realtime.ts +45 -0
- package/src/schema/releases.ts +95 -0
- package/src/schema/roles.ts +27 -0
- package/src/schema/saved-views.ts +33 -0
- package/src/schema/schema-pushes.ts +43 -0
- package/src/schema/search-documents.ts +47 -0
- package/src/schema/search-query-log.ts +24 -0
- package/src/schema/search-recommendations.ts +27 -0
- package/src/schema/search-synonyms.ts +23 -0
- package/src/schema/webhook-deliveries.ts +37 -0
- package/src/schema/webhooks.ts +36 -0
- package/src/schema/workspace-activity.ts +35 -0
- package/src/schema/workspace-domains.ts +24 -0
- package/src/schema/workspace-installed-apps.ts +48 -0
- package/src/schema/workspaces.ts +24 -0
- package/src/schema-kit.ts +64 -0
- package/src/sealed.ts +84 -0
- package/src/seed/skeleton.ts +77 -0
- package/src/sql/apply-gin-indexes.ts +31 -0
- 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
|
+
}
|
package/src/reconcile.ts
ADDED
|
@@ -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
|
+
}
|