@tangen/db 0.0.0-stage → 0.1.55

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,70 @@
1
+ /**
2
+ * File Name: content-ddl.ts
3
+ * Purpose: The DDL of a content type's table and its `_i18n` side table, and
4
+ * the in-place upgrades an existing database gets: the primary key is
5
+ * `id` (and `(object_id, locale)` on the side table). One registry per
6
+ * database, one table per type, rows scoped by workspace_id.
7
+ *
8
+ * Author: Raghunath J
9
+ * License: MIT
10
+ *
11
+ * Dependencies/Notes: `pkeyColumnsQuery` lets a caller detect an older
12
+ * composite key by name before running the upgrade DDL.
13
+ */
14
+
15
+ /** CREATE TABLE for a per-type content table. `fieldColsSql` is the caller's
16
+ * pre-rendered field column list (possibly empty). */
17
+ export function contentTableDdl(tableName: string, fieldColsSql: string): string {
18
+ return `
19
+ CREATE TABLE IF NOT EXISTS "${tableName}" (
20
+ id TEXT NOT NULL,
21
+ workspace_id TEXT NOT NULL,
22
+ title TEXT,
23
+ slug TEXT,
24
+ status TEXT NOT NULL DEFAULT 'draft',
25
+ published_at TIMESTAMPTZ,
26
+ published_by TEXT,
27
+ published_values JSONB,
28
+ scheduled_at TIMESTAMPTZ,
29
+ scheduled_by TEXT,
30
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
31
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
32
+ created_by TEXT,
33
+ updated_by TEXT,
34
+ created_by_kind TEXT,
35
+ updated_by_kind TEXT,
36
+ published_by_kind TEXT${fieldColsSql ? ',\n ' + fieldColsSql : ''},
37
+ PRIMARY KEY (id)
38
+ )
39
+ `
40
+ }
41
+
42
+ /** CREATE TABLE for a per-type i18n side table (localized columns are added
43
+ * by the caller with ALTER ... ADD COLUMN IF NOT EXISTS, as before). */
44
+ export function i18nTableDdl(tableName: string): string {
45
+ return `
46
+ CREATE TABLE IF NOT EXISTS "${tableName}" (
47
+ object_id TEXT NOT NULL,
48
+ locale TEXT NOT NULL,
49
+ "__title" TEXT,
50
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
51
+ PRIMARY KEY (object_id, locale)
52
+ )
53
+ `
54
+ }
55
+
56
+ /**
57
+ * Detection query: the table's PRIMARY KEY column list, as text like
58
+ * "id" or "object_id, locale". Callers compare against the target shape to
59
+ * decide whether the upgrade statements need to run (idempotence).
60
+ */
61
+ export function pkeyColumnsQuery(tableName: string): string {
62
+ return `
63
+ SELECT c.conname AS pkey_name,
64
+ string_agg(a.attname, ', ' ORDER BY array_position(c.conkey, a.attnum)) AS cols
65
+ FROM pg_constraint c
66
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
67
+ WHERE c.contype = 'p' AND c.conrelid = '"${tableName}"'::regclass
68
+ GROUP BY c.conname
69
+ `
70
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * File Name: content-tables.ts
3
+ * Purpose: The RUNTIME registry of content tables: one Drizzle table object
4
+ * per content type the process serves, built from the schema
5
+ * registry rows of those environments - never from a generated file.
6
+ * A process serving several environments on one database gets the
7
+ * physical UNION of their tables, so which environment ran
8
+ * `tangen push` last can never decide what the process can read.
9
+ *
10
+ * Author: Raghunath J
11
+ * License: MIT
12
+ *
13
+ * Dependencies/Notes: `contentTables` and `contentI18nTables` are MUTABLE
14
+ * maps with a stable identity - every consumer imports them once and
15
+ * reads `contentTables[type]` per request; `setContentTables`
16
+ * replaces their entries in place at boot and on every schema
17
+ * change. The generated `app/generated/content-tables.ts` remains
18
+ * codegen and type support (tangen-types.d.ts) and is not loaded here.
19
+ */
20
+ import { pgTable, text, numeric, boolean, timestamp, jsonb, primaryKey } from 'drizzle-orm/pg-core'
21
+ import type { ColumnKind, ContentTableSpec, ContentI18nTableSpec } from '@tangen/types'
22
+
23
+ export type ContentTableMap = Record<string, unknown>
24
+
25
+ /** The content tables this process serves, by type name. Filled by setContentTables. */
26
+ export const contentTables: ContentTableMap = {}
27
+ /** The localized side tables (`<type>_i18n`), by type name. */
28
+ export const contentI18nTables: ContentTableMap = {}
29
+
30
+ /** The Drizzle column for one column kind - the same rule codegen writes down. */
31
+ export function drizzleColumnFor(name: string, kind: ColumnKind) {
32
+ switch (kind) {
33
+ case 'NUMERIC': return numeric(name)
34
+ case 'BOOLEAN': return boolean(name)
35
+ case 'TIMESTAMPTZ': return timestamp(name, { withTimezone: true })
36
+ case 'JSONB': return jsonb(name)
37
+ case 'TEXT[]': return text(name).array()
38
+ // NUMRANGE is read and written as its string literal ("[1,10]"); PG casts on write.
39
+ case 'NUMRANGE':
40
+ case 'TEXT':
41
+ default: return text(name)
42
+ }
43
+ }
44
+
45
+ function systemCols() {
46
+ return {
47
+ // Identity is COMPOSITE - (id) - declared per table below.
48
+ id: text('id').notNull(),
49
+ workspaceId: text('workspace_id').notNull(),
50
+ title: text('title'),
51
+ slug: text('slug'),
52
+ status: text('status').notNull().default('draft'),
53
+ publishedAt: timestamp('published_at', { withTimezone: true }),
54
+ publishedBy: text('published_by'),
55
+ publishedValues: jsonb('published_values'),
56
+ scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
57
+ /** Who scheduled the publish (a member or key id): the actor the scheduler publishes AS (kernel K4). */
58
+ scheduledBy: text('scheduled_by'),
59
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
60
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
61
+ createdBy: text('created_by'),
62
+ updatedBy: text('updated_by'),
63
+ /** The actor KIND behind each attribution id (kernel K5): member | api_key | system | site_user. A system actor has no id, but it has a kind. */
64
+ createdByKind: text('created_by_kind'),
65
+ updatedByKind: text('updated_by_kind'),
66
+ publishedByKind: text('published_by_kind'),
67
+ }
68
+ }
69
+
70
+ /** One content table object from its spec: the system columns plus the field columns. */
71
+ export function buildContentTable(spec: ContentTableSpec) {
72
+ const cols: Record<string, any> = { ...systemCols() }
73
+ for (const c of spec.columns) cols[c.name] = drizzleColumnFor(c.name, c.kind)
74
+ return pgTable(spec.name, cols, (t: any) => ({ pk: primaryKey({ columns: [t.id] }) }))
75
+ }
76
+
77
+ /** One localized side table object from its spec. */
78
+ export function buildContentI18nTable(spec: ContentI18nTableSpec) {
79
+ const cols: Record<string, any> = {
80
+ objectId: text('object_id').notNull(),
81
+ locale: text('locale').notNull(),
82
+ __title: text('__title'),
83
+ }
84
+ for (const c of spec.columns) cols[c.name] = drizzleColumnFor(c.name, c.kind)
85
+ cols.updatedAt = timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
86
+ return pgTable(`${spec.type}_i18n`, cols, (t: any) => ({ pk: primaryKey({ columns: [t.objectId, t.locale] }) }))
87
+ }
88
+
89
+ export interface BuiltContentTables { contentTables: ContentTableMap; contentI18nTables: ContentTableMap }
90
+
91
+ /** Every table object for a set of specs (the union a process serves). */
92
+ export function buildContentTables(tables: ContentTableSpec[], i18n: ContentI18nTableSpec[] = []): BuiltContentTables {
93
+ const out: BuiltContentTables = { contentTables: {}, contentI18nTables: {} }
94
+ for (const spec of tables) out.contentTables[spec.name] = buildContentTable(spec)
95
+ for (const spec of i18n) out.contentI18nTables[spec.type] = buildContentI18nTable(spec)
96
+ return out
97
+ }
98
+
99
+ /** Replaces the registry's entries in place; the exported maps keep their identity. */
100
+ export function setContentTables(built: BuiltContentTables): void {
101
+ for (const k of Object.keys(contentTables)) delete contentTables[k]
102
+ for (const k of Object.keys(contentI18nTables)) delete contentI18nTables[k]
103
+ Object.assign(contentTables, built.contentTables)
104
+ Object.assign(contentI18nTables, built.contentI18nTables)
105
+ }
106
+
107
+ /** Tests only: an empty registry. */
108
+ export function resetContentTables(): void {
109
+ setContentTables({ contentTables: {}, contentI18nTables: {} })
110
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * File Name: custom-tables-loader.ts
3
+ * Purpose: The `@tangen/db/custom-tables` entry — resolves the GENERATED
4
+ * custom-tables Drizzle registry project-first, mirroring the
5
+ * content-tables loader. A consumer project's
6
+ * `app/generated/custom-tables.ts` (written by `tangen push`,
7
+ * committed to git) wins; otherwise the package's bundled
8
+ * `generated/custom-tables.ts` (monorepo / tests) is used; otherwise
9
+ * an empty registry. The API additionally falls back to building the
10
+ * pgTable from the boot-loaded CustomTableDefinition (schema-kit's
11
+ * customTablePg), so a missing/partial file never breaks queries.
12
+ *
13
+ * Author: Raghunath J
14
+ * License: MIT
15
+ *
16
+ * Dependencies/Notes: Project-first: the project's generated file, then the bundled one —
17
+ * the generated schema lives in the PROJECT, not node_modules (which
18
+ * `npm i` wipes). Top-level await makes this an async module.
19
+ */
20
+ import { existsSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+
23
+ type TableMap = Record<string, unknown>
24
+ interface GeneratedModule { customTables?: TableMap }
25
+
26
+ /**
27
+ * Resolve the generated custom-tables MODULE (project-first, then bundled
28
+ * fallback, then empty).
29
+ */
30
+ async function resolveGenerated(
31
+ workspaceRoot: string | undefined,
32
+ bundled: () => Promise<GeneratedModule>,
33
+ ): Promise<GeneratedModule> {
34
+ if (workspaceRoot) {
35
+ const projectFile = join(workspaceRoot, 'app', 'generated', 'custom-tables.ts')
36
+ if (existsSync(projectFile)) {
37
+ try {
38
+ return await import(projectFile)
39
+ } catch {
40
+ // Fall through to the bundled fallback on a bad/partial project file.
41
+ }
42
+ }
43
+ }
44
+ try {
45
+ return await bundled()
46
+ } catch {
47
+ return {}
48
+ }
49
+ }
50
+
51
+ /** Resolve just the custom-tables registry (project-first). Exposed for tests. */
52
+ export async function loadCustomTables(
53
+ workspaceRoot: string | undefined,
54
+ bundled: () => Promise<GeneratedModule>,
55
+ ): Promise<TableMap> {
56
+ return (await resolveGenerated(workspaceRoot, bundled)).customTables ?? {}
57
+ }
58
+
59
+ const _generated: GeneratedModule = await resolveGenerated(
60
+ process.env.TANGEN_WORKSPACE_DIR,
61
+ () => import('./generated/custom-tables'),
62
+ )
63
+
64
+ /** Drizzle pgTable per custom table, keyed by table name. */
65
+ export const customTables: TableMap = _generated.customTables ?? {}
@@ -0,0 +1,195 @@
1
+ /**
2
+ * File Name: ensure-system-schema.ts
3
+ * Purpose: The idempotent system-schema floor, DERIVED from the canonical
4
+ * migrations (closure plan, Phase 1 Unit 5). Tangen has one
5
+ * authoritative definition and evolution path for its system
6
+ * schema: the drizzle migrations under `drizzle/`. This module
7
+ * declares nothing of its own. It extracts, mechanically and in
8
+ * journal order, the statements those migrations already wrote in
9
+ * an idempotent form (CREATE TABLE / CREATE INDEX / ADD COLUMN ...
10
+ * IF NOT EXISTS) so a database the migrator could not bring up to
11
+ * date (no DATABASE_URL, TANGEN_SKIP_MIGRATE=1, a drifted ledger)
12
+ * still gets the tables and columns the current code reads, as a
13
+ * best-effort fallback that can never fail a working database.
14
+ *
15
+ * Author: Raghunath J
16
+ * License: MIT
17
+ *
18
+ * Dependencies/Notes: Runs only when the migrator did not (API boot) or
19
+ * could not (push): when `runMigrations` succeeded the schema is
20
+ * current by definition and the floor has nothing to add. Reads the
21
+ * .sql files the package ships (the same files the migrator runs).
22
+ * Statements inside DO blocks are not extracted: they are the
23
+ * conditional path with its own entrypoint (knowledge-schema.ts).
24
+ * A migration written without IF NOT EXISTS is not derivable and is
25
+ * therefore only reachable through the migrator - the test lists
26
+ * what the floor covers so a gap is a visible decision, not a
27
+ * surprise.
28
+ */
29
+ import { readFileSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+
32
+ const migrationsFolder = join(import.meta.dir, '..', 'drizzle')
33
+
34
+ /** Splits SQL text into statements, honouring quotes, dollar-quoted blocks and `--` comments. */
35
+ export function splitSqlStatements(text: string): string[] {
36
+ const out: string[] = []
37
+ let cur = ''
38
+ let i = 0
39
+ let inSingle = false
40
+ let inDouble = false
41
+ let dollarTag: string | null = null
42
+ while (i < text.length) {
43
+ const ch = text[i]!
44
+ if (dollarTag) {
45
+ if (text.startsWith(dollarTag, i)) { cur += dollarTag; i += dollarTag.length; dollarTag = null; continue }
46
+ cur += ch; i++; continue
47
+ }
48
+ if (inSingle) {
49
+ cur += ch
50
+ if (ch === "'") { if (text[i + 1] === "'") { cur += "'"; i += 2; continue } inSingle = false }
51
+ i++; continue
52
+ }
53
+ if (inDouble) { cur += ch; if (ch === '"') inDouble = false; i++; continue }
54
+ if (ch === '-' && text[i + 1] === '-') { const nl = text.indexOf('\n', i); i = nl === -1 ? text.length : nl; continue }
55
+ if (ch === '$') {
56
+ const m = /^\$[A-Za-z_]*\$/.exec(text.slice(i, i + 64))
57
+ if (m) { dollarTag = m[0]; cur += m[0]; i += m[0].length; continue }
58
+ }
59
+ if (ch === "'") { inSingle = true; cur += ch; i++; continue }
60
+ if (ch === '"') { inDouble = true; cur += ch; i++; continue }
61
+ if (ch === ';') { const s = cur.trim(); if (s) out.push(s); cur = ''; i++; continue }
62
+ cur += ch; i++
63
+ }
64
+ const tail = cur.trim()
65
+ if (tail) out.push(tail)
66
+ return out
67
+ }
68
+
69
+ /** The statement forms that are idempotent by construction, and so safe to run on any database. */
70
+ const IDEMPOTENT = /^(CREATE TABLE IF NOT EXISTS|CREATE (UNIQUE )?INDEX (CONCURRENTLY )?IF NOT EXISTS|ALTER TABLE\s+(?:"[^"]+"|\w+)\s+ADD COLUMN IF NOT EXISTS)\b/i
71
+
72
+ /** Tables migration 0051 drops: the Environment model. Never part of the floor. */
73
+ const DROPPED_TABLES = new Set(['environments', 'member_env_membership', 'member_env_roles', 'promotion_log'])
74
+
75
+ /**
76
+ * Migration 0051 removes the Environment column from every table
77
+ * (tasks/remove-environments-programme.md). It runs as one plpgsql block, so
78
+ * the floor cannot extract it - instead every earlier statement is rewritten
79
+ * the way 0051 leaves the table: no env column, the keys, uniques and
80
+ * indexes re-keyed without it (renamed the way 0051 renames them), and the
81
+ * dropped tables absent. Null when the statement has nothing left to say.
82
+ */
83
+ export function withoutEnvironmentColumns(stmt: string): string | null {
84
+ const table = /^CREATE TABLE IF NOT EXISTS "?([A-Za-z_][A-Za-z0-9_]*)"?/i.exec(stmt)?.[1]
85
+ if (table && DROPPED_TABLES.has(table)) return null
86
+ const indexOn = /^CREATE (?:UNIQUE )?INDEX (?:CONCURRENTLY )?IF NOT EXISTS "?[A-Za-z0-9_]+"?\s+ON\s+"?([A-Za-z_][A-Za-z0-9_]*)"?/i.exec(stmt)?.[1]
87
+ if (indexOn && DROPPED_TABLES.has(indexOn)) return null
88
+ const alterOf = /^ALTER TABLE\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\s+ADD COLUMN IF NOT EXISTS "?([A-Za-z_][A-Za-z0-9_]*)"?/i.exec(stmt)
89
+ if (alterOf && (DROPPED_TABLES.has(alterOf[1]!) || ENV_COLUMNS.has(alterOf[2]!))) return null
90
+ if (!/env_id/i.test(stmt)) return stmt
91
+ const renameIndex = (name: string) => name.replace(/_env_id(?=_|$)/g, '').replace(/_workspace_env_/g, '_workspace_').replace(/_ws_env_/g, '_ws_').replace(/_env_/g, '_')
92
+ if (table) {
93
+ const open = stmt.indexOf('(')
94
+ const close = stmt.lastIndexOf(')')
95
+ const body = stmt.slice(open + 1, close)
96
+ const parts = splitTopLevel(body).map((p) => p.trim()).filter(Boolean)
97
+ const kept: string[] = []
98
+ for (const part of parts) {
99
+ const col = /^"?([A-Za-z_][A-Za-z0-9_]*)"?\s/.exec(part)?.[1]
100
+ if (col && ENV_COLUMNS.has(col) && !/^(CONSTRAINT|PRIMARY|UNIQUE|FOREIGN|CHECK)\b/i.test(part)) continue
101
+ const constraint = /^(CONSTRAINT\s+"?([A-Za-z0-9_]+)"?\s+)?(PRIMARY KEY|UNIQUE)\s*\(([^)]*)\)/i.exec(part)
102
+ if (constraint) {
103
+ const cols = constraint[4]!.split(',').map((c) => c.trim()).filter((c) => !ENV_COLUMNS.has(c.replace(/"/g, '')))
104
+ if (cols.length === 0) continue
105
+ const name = constraint[2] ? `CONSTRAINT "${renameIndex(constraint[2])}" ` : ''
106
+ kept.push(`${name}${constraint[3]}(${cols.join(',')})`)
107
+ continue
108
+ }
109
+ kept.push(part)
110
+ }
111
+ return `${stmt.slice(0, open + 1)}\n\t${kept.join(',\n\t')}\n${stmt.slice(close)}`
112
+ }
113
+ if (indexOn) {
114
+ const m = /^(CREATE (?:UNIQUE )?INDEX (?:CONCURRENTLY )?IF NOT EXISTS )"?([A-Za-z0-9_]+)"?(\s+ON\s+"?[A-Za-z_][A-Za-z0-9_]*"?\s*(?:USING\s+\w+\s*)?)\(([\s\S]*)\)([\s\S]*)$/i.exec(stmt)
115
+ if (!m) return null
116
+ const cols = splitTopLevel(m[4]!).map((c) => c.trim()).filter((c) => !ENV_COLUMNS.has(c.replace(/"/g, '')))
117
+ if (cols.length === 0) return null
118
+ return `${m[1]}"${renameIndex(m[2]!)}"${m[3]}(${cols.join(', ')})${m[5] ?? ''}`
119
+ }
120
+ return stmt
121
+ }
122
+
123
+ const ENV_COLUMNS = new Set(['env_id', 'source_env_id', 'target_env_id', 'from_env_id', 'to_env_id'])
124
+
125
+ /** Splits `a, b(c, d), e` on the commas outside parentheses. */
126
+ function splitTopLevel(text: string): string[] {
127
+ const out: string[] = []
128
+ let depth = 0
129
+ let cur = ''
130
+ for (const ch of text) {
131
+ if (ch === '(') depth++
132
+ if (ch === ')') depth--
133
+ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue }
134
+ cur += ch
135
+ }
136
+ out.push(cur)
137
+ return out
138
+ }
139
+
140
+ /** The floor: every idempotent statement the migrations carry, in journal order, each once, as 0051 leaves it. */
141
+ export function deriveSystemSchemaFloor(migrationSql: readonly string[]): string[] {
142
+ const seen = new Set<string>()
143
+ const out: string[] = []
144
+ for (const file of migrationSql) {
145
+ for (const raw of splitSqlStatements(file)) {
146
+ if (!IDEMPOTENT.test(raw.replace(/\s+/g, ' '))) continue
147
+ const stmt = withoutEnvironmentColumns(raw)
148
+ if (!stmt) continue
149
+ const key = stmt.replace(/\s+/g, ' ')
150
+ if (seen.has(key)) continue
151
+ seen.add(key)
152
+ out.push(stmt)
153
+ }
154
+ }
155
+ return out
156
+ }
157
+
158
+ /** The migration files in journal order, as the migrator reads them. */
159
+ export function readMigrationSql(folder = migrationsFolder): string[] {
160
+ const journal = JSON.parse(readFileSync(join(folder, 'meta', '_journal.json'), 'utf8')) as { entries: Array<{ tag: string }> }
161
+ return journal.entries.map((e) => readFileSync(join(folder, `${e.tag}.sql`), 'utf8'))
162
+ }
163
+
164
+ let derived: readonly string[] | null = null
165
+
166
+ /** The floor for the migrations this package ships (computed once). */
167
+ export function systemSchemaFloor(): readonly string[] {
168
+ return (derived ??= deriveSystemSchemaFloor(readMigrationSql()))
169
+ }
170
+
171
+ /** @deprecated The floor is derived; kept as a name for the callers and tests that read it. */
172
+ export const SYSTEM_COLUMN_GUARDS: readonly string[] = new Proxy([] as string[], {
173
+ get(_, prop) {
174
+ const list = systemSchemaFloor() as string[]
175
+ const v = (list as unknown as Record<PropertyKey, unknown>)[prop]
176
+ return typeof v === 'function' ? (v as (...a: unknown[]) => unknown).bind(list) : v
177
+ },
178
+ has(_, prop) { return prop in (systemSchemaFloor() as string[]) },
179
+ ownKeys() { return Reflect.ownKeys(systemSchemaFloor() as string[]) },
180
+ getOwnPropertyDescriptor(_, prop) { return Reflect.getOwnPropertyDescriptor(systemSchemaFloor() as string[], prop) },
181
+ })
182
+
183
+ /**
184
+ * Runs the derived floor. Best-effort per statement - a failure on one (a
185
+ * base table absent, an extension the server lacks) never blocks the others
186
+ * or the caller; the migrator is the path that reports errors.
187
+ * @param exec - Runs a raw SQL string against the database.
188
+ */
189
+ export async function ensureSystemColumns(
190
+ exec: (sql: string) => Promise<unknown>,
191
+ ): Promise<void> {
192
+ for (const stmt of systemSchemaFloor()) {
193
+ try { await exec(stmt) } catch { /* base table absent / extension missing / transient - the migrator reconciles */ }
194
+ }
195
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * File Name: extension-install-state.ts
3
+ * Purpose: The installation state of an extension in a workspace - installed,
4
+ * enabled, version, options, uninstalled - as the one table
5
+ * `workspace_installed_apps` holds it. Installed, enabled and
6
+ * active are distinct: a row exists once installed, `enabled`
7
+ * switches it, and active means installed AND enabled.
8
+ *
9
+ * Author: Raghunath J
10
+ * License: MIT
11
+ *
12
+ * Dependencies/Notes: Scope is the WORKSPACE (tasks/remove-environments-programme.md):
13
+ * an extension is installed in a deployment's database for a
14
+ * workspace, and every request in that workspace sees the same
15
+ * installation. `InstallExec` is a raw statement runner so the CLI,
16
+ * the API and PGlite tests share one implementation.
17
+ */
18
+ import { and, eq } from 'drizzle-orm'
19
+ import { workspaceInstalledApps } from './schema/workspace-installed-apps'
20
+
21
+ export type InstallExec = (text: string, params?: unknown[]) => Promise<Array<Record<string, unknown>>>
22
+
23
+ export function installExecFromDb(db: unknown): InstallExec {
24
+ const d = db as { $rawClient?: { unsafe: (t: string, p: unknown[]) => Promise<unknown[]> }; session?: { client?: { unsafe: (t: string, p: unknown[]) => Promise<unknown[]> } }; $client?: { query: (t: string, p: unknown[]) => Promise<{ rows: unknown[] }> } }
25
+ const raw = d.$rawClient ?? d.session?.client
26
+ if (raw && typeof raw.unsafe === 'function') return async (text, params) => (await raw.unsafe(text, params ?? [])) as Array<Record<string, unknown>>
27
+ const pg = d.$client ?? (d.session?.client as unknown as { query?: (t: string, p: unknown[]) => Promise<{ rows: unknown[] }> } | undefined)
28
+ if (pg && typeof pg.query === 'function') { const q = pg.query.bind(pg); return async (text, params) => (await q(text, params ?? [])).rows as Array<Record<string, unknown>> }
29
+ throw new Error('installExecFromDb: not a drizzle client this platform knows')
30
+ }
31
+
32
+ export type ExtensionInstallState = 'installed' | 'uninstalled'
33
+
34
+ export interface ExtensionInstallRow {
35
+ id: string
36
+ workspaceId: string
37
+ extensionId: string
38
+ state: ExtensionInstallState
39
+ enabled: boolean
40
+ version: string | null
41
+ options: Record<string, unknown>
42
+ installedAt: Date | string
43
+ installedBy: string | null
44
+ upgradedAt: Date | string | null
45
+ uninstalledAt: Date | string | null
46
+ /** K5.2a: the grants an operator approved; null = not reviewed; undefined = the table has no such column yet. */
47
+ grantedGrants?: string[] | null
48
+ }
49
+
50
+ export interface InstallScope { workspaceId: string; extensionId: string }
51
+
52
+ const COLS = `id, workspace_id, app_id, options, state, enabled, version, installed_at, installed_by, upgraded_at, uninstalled_at`
53
+
54
+ function rowOf(r: Record<string, unknown>): ExtensionInstallRow {
55
+ return {
56
+ id: String(r.id), workspaceId: String(r.workspace_id), extensionId: String(r.app_id),
57
+ state: (r.state as ExtensionInstallState) ?? 'installed', enabled: r.enabled === true || r.enabled === 't',
58
+ version: (r.version as string | null) ?? null,
59
+ options: (typeof r.options === 'string' ? JSON.parse(r.options) : r.options ?? {}) as Record<string, unknown>,
60
+ installedAt: r.installed_at as Date | string, installedBy: (r.installed_by as string | null) ?? null,
61
+ upgradedAt: (r.upgraded_at as Date | string | null) ?? null, uninstalledAt: (r.uninstalled_at as Date | string | null) ?? null,
62
+ ...('granted_grants' in r ? { grantedGrants: parseGrants(r.granted_grants) } : {}),
63
+ }
64
+ }
65
+
66
+ function parseGrants(v: unknown): string[] | null {
67
+ const parsed = typeof v === 'string' ? JSON.parse(v) : v
68
+ return Array.isArray(parsed) ? parsed.filter((g): g is string => typeof g === 'string') : null
69
+ }
70
+
71
+ export async function readInstall(exec: InstallExec, scope: InstallScope): Promise<ExtensionInstallRow | null> {
72
+ const rows = await exec(`SELECT ${COLS} FROM workspace_installed_apps WHERE workspace_id = $1 AND app_id = $2 LIMIT 1`, [scope.workspaceId, scope.extensionId])
73
+ if (!rows[0]) return null
74
+ const row = rowOf(rows[0])
75
+ const grants = await listGrantedGrants(exec, scope)
76
+ return scope.extensionId in grants ? { ...row, grantedGrants: grants[scope.extensionId] ?? null } : row
77
+ }
78
+
79
+ /**
80
+ * K5.2a: the approval record per installed extension of a scope. Read in
81
+ * its own statement so a database whose migration has not run (or a test's
82
+ * hand-built table) answers "nothing recorded" instead of failing every
83
+ * install read.
84
+ */
85
+ export async function listGrantedGrants(exec: InstallExec, scope: { workspaceId: string; extensionId?: string }): Promise<Record<string, string[] | null>> {
86
+ try {
87
+ const rows = scope.extensionId
88
+ ? await exec(`SELECT app_id, granted_grants FROM workspace_installed_apps WHERE workspace_id = $1 AND app_id = $2 AND state = 'installed'`, [scope.workspaceId, scope.extensionId])
89
+ : await exec(`SELECT app_id, granted_grants FROM workspace_installed_apps WHERE workspace_id = $1 AND state = 'installed'`, [scope.workspaceId])
90
+ const out: Record<string, string[] | null> = {}
91
+ for (const r of rows) out[String(r.app_id)] = parseGrants(r.granted_grants)
92
+ return out
93
+ } catch {
94
+ return {}
95
+ }
96
+ }
97
+
98
+ /** K5.2a: record (or clear, with null) the grants an operator approved for an installation. */
99
+ export async function recordGrantedGrants(exec: InstallExec, scope: InstallScope, grants: readonly string[] | null): Promise<void> {
100
+ await exec(
101
+ `UPDATE workspace_installed_apps SET granted_grants = $3::jsonb WHERE workspace_id = $1 AND app_id = $2`,
102
+ [scope.workspaceId, scope.extensionId, grants ? JSON.stringify([...grants]) : null],
103
+ )
104
+ }
105
+
106
+ export async function listInstalls(exec: InstallExec, scope: { workspaceId: string }): Promise<ExtensionInstallRow[]> {
107
+ const rows = await exec(`SELECT ${COLS} FROM workspace_installed_apps WHERE workspace_id = $1 ORDER BY app_id`, [scope.workspaceId])
108
+ return rows.map(rowOf)
109
+ }
110
+
111
+ export async function isExtensionActive(exec: InstallExec, scope: InstallScope): Promise<boolean> {
112
+ const rows = await exec(`SELECT 1 AS one FROM workspace_installed_apps WHERE workspace_id = $1 AND app_id = $2 AND state = 'installed' AND enabled = true LIMIT 1`, [scope.workspaceId, scope.extensionId])
113
+ return rows.length > 0
114
+ }
115
+
116
+ export async function activeExtensionIdsDb(db: unknown, scope: { workspaceId: string }): Promise<string[]> {
117
+ const d = db as { select: (cols: Record<string, unknown>) => { from: (t: unknown) => { where: (w: unknown) => Promise<Array<{ appId: string }>> } } }
118
+ const rows = await d
119
+ .select({ appId: workspaceInstalledApps.appId })
120
+ .from(workspaceInstalledApps)
121
+ .where(and(
122
+ eq(workspaceInstalledApps.workspaceId, scope.workspaceId),
123
+ eq(workspaceInstalledApps.state, 'installed'),
124
+ eq(workspaceInstalledApps.enabled, true),
125
+ ))
126
+ return rows.map((r) => r.appId)
127
+ }
128
+
129
+ export async function activeExtensionIds(exec: InstallExec, scope: { workspaceId: string }): Promise<string[]> {
130
+ const rows = await exec(`SELECT app_id FROM workspace_installed_apps WHERE workspace_id = $1 AND state = 'installed' AND enabled = true ORDER BY app_id`, [scope.workspaceId])
131
+ return rows.map((r) => String(r.app_id))
132
+ }
133
+
134
+ export interface InstallInput {
135
+ workspaceId: string
136
+ extensionId: string
137
+ version: string | null
138
+ options?: Record<string, unknown>
139
+ installedBy?: string | null
140
+ enabled?: boolean
141
+ /** K5.2a: the grants the operator approved at this install; omit to leave the installation unreviewed. */
142
+ grants?: readonly string[] | null
143
+ }
144
+
145
+ export async function installExtension(exec: InstallExec, input: InstallInput): Promise<{ row: ExtensionInstallRow; outcome: 'installed' | 'adopted' | 'already-installed' }> {
146
+ const existing = await readInstall(exec, input)
147
+ const options = JSON.stringify(input.options ?? {})
148
+ if (existing?.state === 'installed') return { row: existing, outcome: 'already-installed' }
149
+ if (existing) {
150
+ await exec(
151
+ `UPDATE workspace_installed_apps SET state = 'installed', enabled = $3, version = $4, options = $5::jsonb, installed_at = now(), installed_by = $6, uninstalled_at = NULL
152
+ WHERE workspace_id = $1 AND app_id = $2`,
153
+ [input.workspaceId, input.extensionId, input.enabled ?? true, input.version, options, input.installedBy ?? null],
154
+ )
155
+ if (input.grants !== undefined) await recordGrantedGrants(exec, input, input.grants)
156
+ return { row: (await readInstall(exec, input))!, outcome: 'adopted' }
157
+ }
158
+ const id = `wext_${Math.random().toString(16).slice(2, 10)}`
159
+ await exec(
160
+ `INSERT INTO workspace_installed_apps (id, workspace_id, app_id, options, state, enabled, version, installed_by)
161
+ VALUES ($1, $2, $3, $4::jsonb, 'installed', $5, $6, $7)`,
162
+ [id, input.workspaceId, input.extensionId, options, input.enabled ?? true, input.version, input.installedBy ?? null],
163
+ )
164
+ if (input.grants !== undefined) await recordGrantedGrants(exec, input, input.grants)
165
+ return { row: (await readInstall(exec, input))!, outcome: 'installed' }
166
+ }
167
+
168
+ export async function setExtensionEnabled(exec: InstallExec, scope: InstallScope, enabled: boolean): Promise<boolean> {
169
+ const rows = await exec(
170
+ `UPDATE workspace_installed_apps SET enabled = $3 WHERE workspace_id = $1 AND app_id = $2 AND state = 'installed' RETURNING id`,
171
+ [scope.workspaceId, scope.extensionId, enabled],
172
+ )
173
+ return rows.length > 0
174
+ }
175
+
176
+ export async function recordInstalledVersion(exec: InstallExec, scope: InstallScope, version: string, upgraded: boolean): Promise<void> {
177
+ await exec(
178
+ `UPDATE workspace_installed_apps SET version = $3${upgraded ? ', upgraded_at = now()' : ''} WHERE workspace_id = $1 AND app_id = $2`,
179
+ [scope.workspaceId, scope.extensionId, version],
180
+ )
181
+ }
182
+
183
+ export async function discardInstall(exec: InstallExec, scope: InstallScope, outcome: 'installed' | 'adopted'): Promise<void> {
184
+ if (outcome === 'adopted') { await uninstallExtension(exec, scope); return }
185
+ await exec(`DELETE FROM workspace_installed_apps WHERE workspace_id = $1 AND app_id = $2`, [scope.workspaceId, scope.extensionId])
186
+ }
187
+
188
+ export async function uninstallExtension(exec: InstallExec, scope: InstallScope): Promise<boolean> {
189
+ const rows = await exec(
190
+ `UPDATE workspace_installed_apps SET state = 'uninstalled', enabled = false, uninstalled_at = now() WHERE workspace_id = $1 AND app_id = $2 AND state = 'installed' RETURNING id`,
191
+ [scope.workspaceId, scope.extensionId],
192
+ )
193
+ return rows.length > 0
194
+ }
195
+
196
+ /**
197
+ * A declaration in app/config/extensions.ts makes an extension AVAILABLE and
198
+ * nothing more: installing and enabling are explicit acts, per deployment
199
+ * (`tangen extension install`). Boot used to adopt every declared extension
200
+ * as installed and enabled, which put SEO and Flows in every new project's
201
+ * sidebar before anyone asked (2026-09-07). What boot still does is learn
202
+ * the declared version for an installed row that predates versions.
203
+ * @returns how many rows learned a version.
204
+ */
205
+ export async function syncDeclaredExtensionVersions(
206
+ exec: InstallExec,
207
+ workspaces: Array<{ workspaceId: string; extensions: Array<{ id: string; version: string }> }>,
208
+ ): Promise<number> {
209
+ let learned = 0
210
+ for (const ws of workspaces) {
211
+ for (const ext of ws.extensions) {
212
+ const rows = await exec(
213
+ `UPDATE workspace_installed_apps SET version = $3 WHERE workspace_id = $1 AND app_id = $2 AND version IS NULL RETURNING id`,
214
+ [ws.workspaceId, ext.id, ext.version],
215
+ )
216
+ learned += rows.length
217
+ }
218
+ }
219
+ return learned
220
+ }