@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.
- 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,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
|
+
}
|