@tangen/db 0.0.0-stage → 0.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +73 -2
  2. package/drizzle/0000_flimsy_shotgun.sql +339 -0
  3. package/drizzle/0001_app_data.sql +11 -0
  4. package/drizzle/0002_add_indexes.sql +36 -0
  5. package/drizzle/0003_extension_data_rename.sql +3 -0
  6. package/drizzle/0004_extension_data_published_globals.sql +12 -0
  7. package/drizzle/0005_auth.sql +51 -0
  8. package/drizzle/0006_auth_identities.sql +13 -0
  9. package/drizzle/0007_ai.sql +18 -0
  10. package/drizzle/0008_ai_chat_threads.sql +5 -0
  11. package/drizzle/0009_forms_source_slug.sql +11 -0
  12. package/drizzle/0010_rbac_roles_status.sql +14 -0
  13. package/drizzle/0011_jobs_v2.sql +67 -0
  14. package/drizzle/0012_search_platform.sql +36 -0
  15. package/drizzle/0013_search_synonym_label.sql +2 -0
  16. package/drizzle/0014_object_types_disabled.sql +2 -0
  17. package/drizzle/0015_playground_collections.sql +25 -0
  18. package/drizzle/0016_catchup_activity_promotion.sql +36 -0
  19. package/drizzle/0017_fields_disabled.sql +3 -0
  20. package/drizzle/0018_object_types_plural.sql +1 -0
  21. package/drizzle/0019_member_profile.sql +2 -0
  22. package/drizzle/0020_paused_jobs.sql +7 -0
  23. package/drizzle/0021_form_submission_notes.sql +2 -0
  24. package/drizzle/0022_paused_jobs_env_reason.sql +4 -0
  25. package/drizzle/0023_scale_indexes.sql +3 -0
  26. package/drizzle/0024_migration_runs.sql +24 -0
  27. package/drizzle/0025_porter_transfer.sql +30 -0
  28. package/drizzle/0026_cron_timezone.sql +1 -0
  29. package/drizzle/0027_rate_limit_counters.sql +6 -0
  30. package/drizzle/0028_realtime_events.sql +10 -0
  31. package/drizzle/0030_delivery_artifacts.sql +10 -0
  32. package/drizzle/0031_realtime_presence.sql +8 -0
  33. package/drizzle/0032_schema_pushes.sql +15 -0
  34. package/drizzle/0033_studio_design_capability.sql +37 -0
  35. package/drizzle/0034_object_types_provided_by.sql +12 -0
  36. package/drizzle/0035_custom_table_owners.sql +24 -0
  37. package/drizzle/0036_revision_version_unique.sql +42 -0
  38. package/drizzle/0037_job_queue_dedupe.sql +20 -0
  39. package/drizzle/0038_search_documents.sql +87 -0
  40. package/drizzle/0039_job_prune_indexes.sql +25 -0
  41. package/drizzle/0040_extension_data_object_idx.sql +10 -0
  42. package/drizzle/0041_knowledge.sql +79 -0
  43. package/drizzle/0042_schema_pushes_checksum.sql +7 -0
  44. package/drizzle/0043_guard_adoption.sql +12 -0
  45. package/drizzle/0044_retention_indexes.sql +7 -0
  46. package/drizzle/0045_extension_install_per_env.sql +40 -0
  47. package/drizzle/0046_extension_migrations.sql +18 -0
  48. package/drizzle/0047_environment_role.sql +25 -0
  49. package/drizzle/0048_environment_role_adoption.sql +30 -0
  50. package/drizzle/0049_release_lifecycle.sql +55 -0
  51. package/drizzle/0050_member_env_membership.sql +21 -0
  52. package/drizzle/0051_remove_environments.sql +350 -0
  53. package/drizzle/0052_release_actor_columns.sql +9 -0
  54. package/drizzle/0053_kernel_event_record.sql +26 -0
  55. package/drizzle/0054_kernel_run_identity.sql +13 -0
  56. package/drizzle/0055_kernel_identities.sql +19 -0
  57. package/drizzle/0056_forms_leave_core.sql +7 -0
  58. package/drizzle/0057_auth_leave_core.sql +8 -0
  59. package/drizzle/0058_member_grants.sql +11 -0
  60. package/drizzle/0059_extension_granted_grants.sql +7 -0
  61. package/drizzle/0060_connections.sql +21 -0
  62. package/drizzle/meta/0000_snapshot.json +1692 -0
  63. package/drizzle/meta/0002_snapshot.json +1935 -0
  64. package/drizzle/meta/0003_snapshot.json +1935 -0
  65. package/drizzle/meta/_journal.json +426 -0
  66. package/package.json +85 -4
  67. package/src/cache-keys.ts +43 -0
  68. package/src/client.ts +136 -0
  69. package/src/content-ddl.ts +70 -0
  70. package/src/content-tables.ts +110 -0
  71. package/src/custom-tables-loader.ts +65 -0
  72. package/src/ensure-system-schema.ts +195 -0
  73. package/src/extension-install-state.ts +220 -0
  74. package/src/extension-migrations.ts +270 -0
  75. package/src/generated/content-tables.ts +271 -0
  76. package/src/generated/custom-tables.ts +18 -0
  77. package/src/index.ts +24 -0
  78. package/src/instrument.ts +139 -0
  79. package/src/knowledge-schema.ts +232 -0
  80. package/src/migrate.ts +67 -0
  81. package/src/prepare-policy.ts +118 -0
  82. package/src/reconcile.ts +115 -0
  83. package/src/schema/ai-chats.ts +36 -0
  84. package/src/schema/ai-settings.ts +24 -0
  85. package/src/schema/api-keys.ts +37 -0
  86. package/src/schema/audit-log.ts +54 -0
  87. package/src/schema/connections.ts +35 -0
  88. package/src/schema/custom-table-owners.ts +32 -0
  89. package/src/schema/delivery-artifacts.ts +33 -0
  90. package/src/schema/entity-tags.ts +33 -0
  91. package/src/schema/extension-data.ts +38 -0
  92. package/src/schema/extension-migrations.ts +42 -0
  93. package/src/schema/fields.ts +37 -0
  94. package/src/schema/globals.ts +35 -0
  95. package/src/schema/identities.ts +27 -0
  96. package/src/schema/index.ts +51 -0
  97. package/src/schema/jobs.ts +104 -0
  98. package/src/schema/knowledge.ts +85 -0
  99. package/src/schema/media.ts +52 -0
  100. package/src/schema/member-grants.ts +34 -0
  101. package/src/schema/members.ts +49 -0
  102. package/src/schema/migration-runs.ts +46 -0
  103. package/src/schema/object-types.ts +41 -0
  104. package/src/schema/objects.ts +38 -0
  105. package/src/schema/paused-jobs.ts +25 -0
  106. package/src/schema/playground.ts +43 -0
  107. package/src/schema/porter-transfer.ts +47 -0
  108. package/src/schema/realtime.ts +45 -0
  109. package/src/schema/releases.ts +95 -0
  110. package/src/schema/roles.ts +27 -0
  111. package/src/schema/saved-views.ts +33 -0
  112. package/src/schema/schema-pushes.ts +43 -0
  113. package/src/schema/search-documents.ts +47 -0
  114. package/src/schema/search-query-log.ts +24 -0
  115. package/src/schema/search-recommendations.ts +27 -0
  116. package/src/schema/search-synonyms.ts +23 -0
  117. package/src/schema/webhook-deliveries.ts +37 -0
  118. package/src/schema/webhooks.ts +36 -0
  119. package/src/schema/workspace-activity.ts +35 -0
  120. package/src/schema/workspace-domains.ts +24 -0
  121. package/src/schema/workspace-installed-apps.ts +48 -0
  122. package/src/schema/workspaces.ts +24 -0
  123. package/src/schema-kit.ts +64 -0
  124. package/src/sealed.ts +84 -0
  125. package/src/seed/skeleton.ts +77 -0
  126. package/src/sql/apply-gin-indexes.ts +31 -0
  127. package/src/sql/gin-indexes.sql +11 -0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * File Name: saved-views.ts
3
+ * Purpose: Defines the `saved_views` table, which persists named filter presets
4
+ * for content-type list views in the admin UI.
5
+ *
6
+ * Author: Raghunath J
7
+ * License: MIT
8
+ *
9
+ * Dependencies/Notes: drizzle-orm/pg-core; visibility controls whether a view is
10
+ * private to its creator or shared across the workspace.
11
+ */
12
+ import { pgTable, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core'
13
+ import { sql } from 'drizzle-orm'
14
+
15
+ /**
16
+ * Stores a named, reusable filter configuration for a specific content type's list
17
+ * view, optionally shared with other workspace members.
18
+ */
19
+ export const savedViews = pgTable('saved_views', {
20
+ id: text('id').primaryKey(),
21
+ workspaceId: text('workspace_id').notNull(),
22
+ typeName: text('type_name').notNull(),
23
+ name: text('name').notNull(),
24
+ /** Serialised FilterState — status, search, dateFilters, fieldFilters */
25
+ filters: jsonb('filters').notNull().default(sql`'{}'`),
26
+ /** 'private' = only creator sees it; 'shared' = all workspace members see it */
27
+ visibility: text('visibility').notNull().default('private'),
28
+ createdBy: text('created_by').notNull(),
29
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
30
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
31
+ }, (t) => ({
32
+ workspaceIdx: index('saved_views_workspace_idx').on(t.workspaceId),
33
+ }))
@@ -0,0 +1,43 @@
1
+ /**
2
+ * File Name: schema-pushes.ts
3
+ * Purpose: The per-environment push ledger: one row per `tangen push` that
4
+ * reached the apply phase - started, finished, status (running /
5
+ * applied / failed), the git sha and CLI version that ran it, the
6
+ * plan and statements it applied, and the error when it failed.
7
+ * `/api/v1/types/health`, the Schema Builder banner and `tangen
8
+ * doctor` read the last row to say whether the last push finished.
9
+ *
10
+ * Author: Raghunath J
11
+ * License: MIT
12
+ *
13
+ * Dependencies/Notes: drizzle-orm/pg-core. Migration 0032 and the
14
+ * ensure-system-schema guard create it; 0042 adds `checksum`.
15
+ * `--plan` never writes here; a row left at `running` means the
16
+ * process died mid-push.
17
+ */
18
+ import { pgTable, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core'
19
+ import { sql } from 'drizzle-orm'
20
+
21
+ export const schemaPushes = pgTable('schema_pushes', {
22
+ id: text('id').primaryKey(),
23
+ workspaceId: text('workspace_id').notNull(),
24
+ startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
25
+ finishedAt: timestamp('finished_at', { withTimezone: true }),
26
+ /** running | applied | failed */
27
+ status: text('status').notNull().default('running'),
28
+ gitSha: text('git_sha'),
29
+ cliVersion: text('cli_version'),
30
+ /** The content plan (planToJson shape) this push applied. */
31
+ planJson: jsonb('plan_json').notNull().default(sql`'{}'`),
32
+ /** The migration statements this push executed, in order. */
33
+ statementsJson: jsonb('statements_json').notNull().default(sql`'[]'`),
34
+ error: text('error'),
35
+ /**
36
+ * sha256 of the canonical plan summary and the ordered statement SQL
37
+ * (migration 0042). The next push re-fingerprints every row and refuses
38
+ * when one disagrees; NULL marks a row written before checksums existed.
39
+ */
40
+ checksum: text('checksum'),
41
+ }, (t) => ({
42
+ startedIdx: index('schema_pushes_started_idx').on(t.startedAt),
43
+ }))
@@ -0,0 +1,47 @@
1
+ /**
2
+ * File Name: search-documents.ts
3
+ * Purpose: The content-search index, held in PostgreSQL rather than in the
4
+ * application process.
5
+ *
6
+ * Author: Raghunath J
7
+ * License: MIT
8
+ *
9
+ * Dependencies/Notes: Identity is (workspace, environment, source, type,
10
+ * object, LOCALE). The locale is part of it because `cfg` - the text-search
11
+ * configuration - is per row: a French document must be stemmed and queried
12
+ * as French, and one row holding every translation could only be stemmed once.
13
+ *
14
+ * `search_vector` is a GENERATED column (migration 0038) computed from `cfg`,
15
+ * so it is deliberately absent here: nothing writes it, and typing it would
16
+ * invite something to try. `cfg` is `regconfig` in the database and is only
17
+ * ever written through the engine's raw upsert, which casts explicitly.
18
+ */
19
+ import { pgTable, text, boolean, jsonb, timestamp, primaryKey, index } from 'drizzle-orm/pg-core'
20
+
21
+ export const searchDocuments = pgTable('search_documents', {
22
+ workspaceId: text('workspace_id').notNull(),
23
+ /** 'content' | 'media' | 'ext:<extensionId>:<sourceId>' - and the unit of uninstall. */
24
+ source: text('source').notNull(),
25
+ typeSlug: text('type_slug').notNull(),
26
+ objectId: text('object_id').notNull(),
27
+ /** '' for content that is not localized. Part of the identity, never a filter afterthought. */
28
+ locale: text('locale').notNull().default(''),
29
+ /** PostgreSQL text-search configuration name; `simple` is the honest fallback. */
30
+ cfg: text('cfg').notNull().default('simple'),
31
+ title: text('title').notNull().default(''),
32
+ slug: text('slug'),
33
+ body: text('body').notNull().default(''),
34
+ /** Bounded snippet source - ts_headline is linear in the text handed to it. */
35
+ excerpt: text('excerpt').notNull().default(''),
36
+ status: text('status').notNull().default('draft'),
37
+ isPublished: boolean('is_published').notNull().default(false),
38
+ metadata: jsonb('metadata').notNull().default({}),
39
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
40
+ }, (t) => ({
41
+ pk: primaryKey({ columns: [t.workspaceId, t.source, t.typeSlug, t.objectId, t.locale] }),
42
+ slugIdx: index('search_documents_slug').on(t.workspaceId, t.slug),
43
+ scopeIdx: index('search_documents_scope').on(t.workspaceId, t.isPublished, t.typeSlug),
44
+ objectIdx: index('search_documents_object').on(t.workspaceId, t.source, t.objectId),
45
+ }))
46
+
47
+ export type SearchDocumentRow = typeof searchDocuments.$inferSelect
@@ -0,0 +1,24 @@
1
+ /**
2
+ * File Name: search-query-log.ts
3
+ * Purpose: Search analytics — one row per search query (Admin or Site Search),
4
+ * for the Settings → Search dashboards (top searches, zero-result, volume).
5
+ * NO PII: the term + result count + audience + timestamp only. Rows are purged
6
+ * past the workspace's retention window (hard-capped at 90 days).
7
+ *
8
+ * Author: Raghunath J
9
+ * License: MIT
10
+ */
11
+ import { pgTable, text, integer, timestamp, index } from 'drizzle-orm/pg-core'
12
+
13
+ export const searchQueryLog = pgTable('search_query_log', {
14
+ id: text('id').primaryKey(),
15
+ workspaceId: text('workspace_id').notNull(),
16
+ /** 'internal' (Admin Search) or 'public' (Site Search). */
17
+ audience: text('audience').notNull(),
18
+ /** The query term. No PII beyond what the user typed. */
19
+ term: text('term').notNull(),
20
+ resultCount: integer('result_count').notNull().default(0),
21
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
22
+ }, (t) => ({
23
+ byWorkspace: index('search_query_log_ws_idx').on(t.workspaceId, t.createdAt),
24
+ }))
@@ -0,0 +1,27 @@
1
+ /**
2
+ * File Name: search-recommendations.ts
3
+ * Purpose: Marketing-managed query curation ("query rules", Settings → Search).
4
+ * For a query (exact or pattern), pin results to the top, and/or hide results.
5
+ * Applied at query time after the providers run.
6
+ *
7
+ * Author: Raghunath J
8
+ * License: MIT
9
+ */
10
+ import { pgTable, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core'
11
+
12
+ export const searchRecommendations = pgTable('search_recommendations', {
13
+ id: text('id').primaryKey(),
14
+ workspaceId: text('workspace_id').notNull(),
15
+ /** The query this rule applies to (matched case-insensitively). */
16
+ query: text('query').notNull(),
17
+ /** Result ids (or urls) to pin to the top, in order (string[]). */
18
+ pins: jsonb('pins').notNull().default([]),
19
+ /** Result ids (or urls) to remove from results (string[]). */
20
+ hides: jsonb('hides').notNull().default([]),
21
+ /** Optional audience scope: 'internal' | 'public' | null (both). */
22
+ audience: text('audience'),
23
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
24
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
25
+ }, (t) => ({
26
+ byWorkspace: index('search_recommendations_ws_idx').on(t.workspaceId),
27
+ }))
@@ -0,0 +1,23 @@
1
+ /**
2
+ * File Name: search-synonyms.ts
3
+ * Purpose: Marketing-managed synonym groups (Settings → Search). A group is a set
4
+ * of interchangeable terms ("laptop", "notebook"); a query matching any term is
5
+ * expanded to match the others. Applied at query time in the search pipeline.
6
+ *
7
+ * Author: Raghunath J
8
+ * License: MIT
9
+ */
10
+ import { pgTable, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core'
11
+
12
+ export const searchSynonyms = pgTable('search_synonyms', {
13
+ id: text('id').primaryKey(),
14
+ workspaceId: text('workspace_id').notNull(),
15
+ /** Human title for the group (e.g. "Footwear"), so marketing knows its purpose. */
16
+ label: text('label'),
17
+ /** Interchangeable terms in this group (string[]). */
18
+ terms: jsonb('terms').notNull().default([]),
19
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
20
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
21
+ }, (t) => ({
22
+ byWorkspace: index('search_synonyms_ws_idx').on(t.workspaceId),
23
+ }))
@@ -0,0 +1,37 @@
1
+ /**
2
+ * File Name: webhook-deliveries.ts
3
+ * Purpose: Defines the `webhook_deliveries` table, which records individual outbound
4
+ * HTTP delivery attempts for webhook events including response details and success status.
5
+ *
6
+ * Author: Raghunath J
7
+ * License: MIT
8
+ *
9
+ * Dependencies/Notes: drizzle-orm/pg-core; rows are append-only delivery receipts — one row
10
+ * per attempt, including retries.
11
+ */
12
+ import { pgTable, text, timestamp, jsonb, boolean, integer, index } from 'drizzle-orm/pg-core'
13
+
14
+ /**
15
+ * Records a single outbound webhook delivery attempt, capturing the event payload sent,
16
+ * the HTTP response code and body received, latency, and whether the delivery succeeded.
17
+ */
18
+ export const webhookDeliveries = pgTable('webhook_deliveries', {
19
+ id: text('id').primaryKey(),
20
+ webhookId: text('webhook_id').notNull(),
21
+ workspaceId: text('workspace_id').notNull(),
22
+ event: text('event').notNull(),
23
+ typeName: text('type_name').notNull(),
24
+ objectId: text('object_id').notNull(),
25
+ payload: jsonb('payload').notNull(),
26
+ statusCode: integer('status_code'),
27
+ responseBody: text('response_body'),
28
+ durationMs: integer('duration_ms'),
29
+ success: boolean('success').notNull(),
30
+ error: text('error'),
31
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
32
+ }, (t) => ({
33
+ workspaceIdx: index('webhook_deliveries_workspace_idx').on(t.workspaceId),
34
+ webhookIdx: index('webhook_deliveries_webhook_idx').on(t.webhookId),
35
+ /** Retention: the purge scans by workspace and age (migration 0044). */
36
+ retentionIdx: index('webhook_deliveries_retention_idx').on(t.workspaceId, t.createdAt),
37
+ }))
@@ -0,0 +1,36 @@
1
+ /**
2
+ * File Name: webhooks.ts
3
+ * Purpose: Defines the `webhooks` table, which stores registered outbound webhook
4
+ * endpoints including their subscribed events, signing secret, and auto-pause state.
5
+ *
6
+ * Author: Raghunath J
7
+ * License: MIT
8
+ *
9
+ * Dependencies/Notes: drizzle-orm/pg-core; consecutiveFailures drives auto-pause logic
10
+ * in the webhook dispatcher.
11
+ */
12
+ import { pgTable, text, timestamp, jsonb, boolean, integer, index } from 'drizzle-orm/pg-core'
13
+ import { sql } from 'drizzle-orm'
14
+
15
+ /**
16
+ * Stores a webhook registration for a workspace environment, including the target URL,
17
+ * optional HMAC signing secret, subscribed event types, custom headers, and auto-pause
18
+ * state based on consecutive delivery failures.
19
+ */
20
+ export const webhooks = pgTable('webhooks', {
21
+ id: text('id').primaryKey(),
22
+ workspaceId: text('workspace_id').notNull(),
23
+ label: text('label').notNull(),
24
+ url: text('url').notNull(),
25
+ secret: text('secret'),
26
+ events: text('events').array().notNull().default(sql`'{}'`),
27
+ typeNames: text('type_names').array().notNull().default(sql`'{}'`),
28
+ enabled: boolean('enabled').notNull().default(true),
29
+ headers: jsonb('headers').notNull().default(sql`'{}'`),
30
+ consecutiveFailures: integer('consecutive_failures').notNull().default(0),
31
+ pausedReason: text('paused_reason'),
32
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
33
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
34
+ }, (t) => ({
35
+ workspaceIdx: index('webhooks_workspace_idx').on(t.workspaceId),
36
+ }))
@@ -0,0 +1,35 @@
1
+ /**
2
+ * File Name: workspace-activity.ts
3
+ * Purpose: Defines the `workspace_activity` table, which is an append-only audit log
4
+ * recording significant actions taken by members or API keys across a workspace.
5
+ *
6
+ * Author: Raghunath J
7
+ * License: MIT
8
+ *
9
+ * Dependencies/Notes: drizzle-orm/pg-core; actor and object fields are denormalized at
10
+ * write time so the log remains readable even after records are deleted.
11
+ */
12
+ import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core'
13
+
14
+ /**
15
+ * Append-only audit log entry capturing who did what, on which object, via which
16
+ * interface (admin, REST, GraphQL, SDK) within a workspace or environment.
17
+ */
18
+ export const workspaceActivity = pgTable('workspace_activity', {
19
+ id: text('id').primaryKey(),
20
+ workspaceId: text('workspace_id').notNull(),
21
+ category: text('category').notNull(), // 'content' | 'schema' | 'members' | 'webhooks' | 'api-keys' | 'media'
22
+ action: text('action').notNull(), // 'created' | 'published' | 'unpublished' | 'deleted' | 'scheduled' | 'pushed' | 'invited' | 'revoked' | 'uploaded' | 'updated'
23
+ actorId: text('actor_id'),
24
+ actorName: text('actor_name'), // denormalized at write time
25
+ actorType: text('actor_type'), // 'member' | 'api_key'
26
+ source: text('source').notNull().default('admin'), // 'admin' | 'rest' | 'graphql' | 'sdk'
27
+ typeName: text('type_name'), // content type slug, e.g. 'post'
28
+ objectId: text('object_id'),
29
+ objectTitle: text('object_title'),
30
+ targetLabel: text('target_label'), // human label: webhook name, member email, api key label
31
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
32
+ }, (t) => ({
33
+ idxWorkspaceCreated: index('workspace_activity_workspace_created').on(t.workspaceId, t.createdAt),
34
+ idxCreated: index('workspace_activity_created').on(t.createdAt),
35
+ }))
@@ -0,0 +1,24 @@
1
+ /**
2
+ * File Name: workspace-domains.ts
3
+ * Purpose: Defines the `workspace_domains` table, which maps verified custom domains
4
+ * to their owning workspace for tenant-aware routing.
5
+ *
6
+ * Author: Raghunath J
7
+ * License: MIT
8
+ *
9
+ * Dependencies/Notes: drizzle-orm/pg-core; domain column has a unique constraint — one
10
+ * domain cannot be claimed by multiple workspaces.
11
+ */
12
+ import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
13
+ import { workspaces } from './workspaces'
14
+
15
+ /**
16
+ * Associates a verified custom domain with a workspace, enabling domain-based tenant
17
+ * resolution for the API and delivery layer.
18
+ */
19
+ export const workspaceDomains = pgTable('workspace_domains', {
20
+ id: text('id').primaryKey(),
21
+ workspaceId: text('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
22
+ domain: text('domain').notNull().unique(),
23
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
24
+ })
@@ -0,0 +1,48 @@
1
+ /**
2
+ * File Name: workspace-installed-apps.ts
3
+ * Purpose: The extension installation state, one row per workspace x
4
+ * environment x extension (closure plan Phase 4, Unit 1): whether
5
+ * it is installed or retired, enabled, at which version, and when
6
+ * it was installed, upgraded or uninstalled. The row is the INSTALL
7
+ * / ENABLE / UPGRADE / UNINSTALL truth; the runtime registry reads
8
+ * it, never a second source.
9
+ *
10
+ * Author: Raghunath J
11
+ * License: MIT
12
+ *
13
+ * Dependencies/Notes: Migration 0045 adds the environment scope and adopts
14
+ * the workspace-wide rows that existed before it: one row per
15
+ * environment of the workspace (from `environments`), enabled,
16
+ * version unknown until the first boot records the manifest's.
17
+ * `state` is `installed` or `uninstalled`; an uninstalled row is
18
+ * the tombstone that keeps retained extension data diagnosable and
19
+ * lets a reinstall adopt it. Uniqueness is (workspace, env, app).
20
+ */
21
+ import { pgTable, text, timestamp, jsonb, boolean, uniqueIndex } from 'drizzle-orm/pg-core'
22
+ import { sql } from 'drizzle-orm'
23
+
24
+ export const workspaceInstalledApps = pgTable(
25
+ 'workspace_installed_apps',
26
+ {
27
+ id: text('id').primaryKey(),
28
+ workspaceId: text('workspace_id').notNull(),
29
+ /** The environment this installation belongs to (`env_<slug>`). */
30
+ appId: text('app_id').notNull(),
31
+ options: jsonb('options').notNull().default(sql`'{}'`),
32
+ /** `installed` | `uninstalled` (retired; data retained, diagnosable, adoptable). */
33
+ state: text('state').notNull().default('installed'),
34
+ /** ENABLE: may contribute active behaviour in this environment. */
35
+ enabled: boolean('enabled').notNull().default(true),
36
+ /** The extension version this installation runs; null until recorded. */
37
+ version: text('version'),
38
+ installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(),
39
+ installedBy: text('installed_by'),
40
+ upgradedAt: timestamp('upgraded_at', { withTimezone: true }),
41
+ uninstalledAt: timestamp('uninstalled_at', { withTimezone: true }),
42
+ /** K5.2a: the grants an operator approved (JSON array); NULL = not reviewed, the manifest's required grants apply. */
43
+ grantedGrants: jsonb('granted_grants'),
44
+ },
45
+ (t) => ({
46
+ uniqueWorkspaceApp: uniqueIndex('workspace_installed_apps_ws_app_unique').on(t.workspaceId, t.appId),
47
+ }),
48
+ )
@@ -0,0 +1,24 @@
1
+ /**
2
+ * File Name: workspaces.ts
3
+ * Purpose: Defines the `workspaces` table, the top-level multi-tenant boundary; every
4
+ * other resource in the platform is scoped to a workspace.
5
+ *
6
+ * Author: Raghunath J
7
+ * License: MIT
8
+ *
9
+ * Dependencies/Notes: drizzle-orm/pg-core; soft-delete via deletedAt — rows with a
10
+ * non-null deletedAt are treated as decommissioned workspaces.
11
+ */
12
+ import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
13
+
14
+ /**
15
+ * Top-level tenant record representing a single organization or project space;
16
+ * all environments, members, API keys, and content types belong to a workspace.
17
+ */
18
+ export const workspaces = pgTable('workspaces', {
19
+ id: text('id').primaryKey(),
20
+ name: text('name').notNull(),
21
+ slug: text('slug').notNull().unique(),
22
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
23
+ deletedAt: timestamp('deleted_at', { withTimezone: true }),
24
+ })
@@ -0,0 +1,64 @@
1
+ /**
2
+ * File Name: schema-kit.ts
3
+ * Purpose: Re-exports the drizzle-orm/pg-core primitives used by generated
4
+ * content-tables.ts / custom-tables.ts. The generated files import from
5
+ * HERE (@tangen/db, a top-level project dep) instead of drizzle-orm
6
+ * directly — because drizzle-orm is a NESTED transitive dep and isn't
7
+ * resolvable from a consumer project's app/generated/ location.
8
+ * Also home of customTablePg() — the SINGLE col.kind → drizzle-column
9
+ * mapping for custom tables, used by both the generated registry file
10
+ * and the API's build-from-definition fallback.
11
+ *
12
+ * Author: Raghunath J
13
+ * License: MIT
14
+ */
15
+ import { pgTable, text, numeric, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core'
16
+
17
+ export { pgTable, text, numeric, boolean, timestamp, jsonb, index, uniqueIndex, primaryKey } from 'drizzle-orm/pg-core'
18
+ export { sql } from 'drizzle-orm'
19
+
20
+ // Structural mirror of @tangen/types' ColumnDefinition / CustomTableDefinition —
21
+ // declared locally so @tangen/db takes no dependency on @tangen/types.
22
+ export interface CustomTableColumnDef {
23
+ kind: 'text' | 'number' | 'boolean' | 'json' | 'timestamp'
24
+ nullable?: boolean
25
+ defaultValue?: unknown
26
+ defaultNow?: boolean
27
+ }
28
+
29
+ export interface CustomTablePgDef {
30
+ name: string
31
+ columns: Record<string, CustomTableColumnDef>
32
+ }
33
+
34
+ function columnFor(name: string, def: CustomTableColumnDef) {
35
+ switch (def.kind) {
36
+ case 'number': return numeric(name)
37
+ case 'boolean': return boolean(name)
38
+ case 'json': return jsonb(name)
39
+ case 'timestamp': return timestamp(name, { withTimezone: true })
40
+ case 'text':
41
+ default: return text(name)
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Build the Drizzle pgTable for a custom-table definition. JS keys are the
47
+ * EXACT column names (workspace_id, not workspaceId) so drizzle select() rows
48
+ * are byte-identical to the raw `SELECT *` rows the old SQL path returned.
49
+ * DDL stays owned by the CLI's custom-table-migration (this object is the
50
+ * query/execution layer only, so column defaults are not re-declared here).
51
+ */
52
+ export function customTablePg(def: CustomTablePgDef) {
53
+ // Builders are heterogeneous (text/numeric/boolean/…): keep the record loose;
54
+ // callers consume the table as an untyped registry entry anyway.
55
+ const cols: Record<string, any> = {
56
+ id: text('id').primaryKey().notNull(),
57
+ workspace_id: text('workspace_id').notNull(),
58
+ }
59
+ for (const [colName, colDef] of Object.entries(def.columns ?? {})) {
60
+ const col = columnFor(colName, colDef)
61
+ cols[colName] = colDef.nullable === false ? col.notNull() : col
62
+ }
63
+ return pgTable(def.name, cols)
64
+ }
package/src/sealed.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * File Name: sealed.ts
3
+ * Purpose: Secrets at rest for connections (Extension Kernel K6.1/K6.3,
4
+ * tasks/plan-extension-kernel.md): an AES-256-GCM envelope under a
5
+ * key derived from the deployment's connections key material. The
6
+ * database holds `v1.<iv>.<tag>.<ciphertext>` and never plaintext;
7
+ * a different key or a tampered byte fails to open. Lives in
8
+ * @tangen/db so the API (which seals and opens) and the CLI (which
9
+ * rotates the key and diagnoses unreadable rows) share ONE
10
+ * envelope. Pure over node:crypto and a SQL executor.
11
+ *
12
+ * Author: Raghunath J
13
+ * License: MIT
14
+ *
15
+ * Dependencies/Notes: One key per deployment (`connections.encryptionKey`,
16
+ * `TANGEN_CONNECTIONS_KEY`, else derived from the member session
17
+ * secret). Rotation re-seals every row under the new key; a row
18
+ * neither key opens is reported, never dropped.
19
+ */
20
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
21
+
22
+ export type SealedKey = Buffer & { readonly __sealedKey: true }
23
+
24
+ type SqlExec = (text: string, params?: unknown[]) => Promise<Array<Record<string, unknown>>>
25
+
26
+ /** A 32-byte key from any key material (a passphrase, a base64 blob): sha256, so a short or long secret both work. */
27
+ export function deriveConnectionsKey(material: string): SealedKey {
28
+ if (!material || material.length < 8) throw new Error('The connections key material must be at least 8 characters')
29
+ return createHash('sha256').update(`tangen.connections.v1:${material}`).digest() as SealedKey
30
+ }
31
+
32
+ /** `v1.<iv>.<tag>.<ciphertext>`, base64url; a fresh iv every call. */
33
+ export function sealSecret(key: SealedKey, value: unknown): string {
34
+ const iv = randomBytes(12)
35
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
36
+ const data = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()])
37
+ const tag = cipher.getAuthTag()
38
+ return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${data.toString('base64url')}`
39
+ }
40
+
41
+ /** The value back, or a throw: wrong key, tampered envelope, unknown version. */
42
+ export function openSecret<T = unknown>(key: SealedKey, sealed: string): T {
43
+ const [version, iv, tag, data] = sealed.split('.')
44
+ if (version !== 'v1' || !iv || !tag || !data) throw new Error('Not a sealed secret this platform understands')
45
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'))
46
+ decipher.setAuthTag(Buffer.from(tag, 'base64url'))
47
+ const plain = Buffer.concat([decipher.update(Buffer.from(data, 'base64url')), decipher.final()])
48
+ return JSON.parse(plain.toString('utf8')) as T
49
+ }
50
+
51
+ function opens(key: SealedKey, sealed: unknown): boolean {
52
+ try { openSecret(key, String(sealed)); return true } catch { return false }
53
+ }
54
+
55
+ export interface UnreadableConnection { id: string; workspaceId: string; connectorId: string; label: string }
56
+
57
+ /** The connection rows, every workspace, that this key cannot open - what Doctor names as CONNECTION_UNREADABLE. */
58
+ export async function unreadableConnections(exec: SqlExec, key: SealedKey): Promise<UnreadableConnection[]> {
59
+ const rows = await exec(`SELECT id, workspace_id, connector_id, label, secret FROM connections ORDER BY workspace_id, created_at`)
60
+ return rows
61
+ .filter((r) => !opens(key, r.secret))
62
+ .map((r) => ({ id: String(r.id), workspaceId: String(r.workspace_id), connectorId: String(r.connector_id), label: String(r.label) }))
63
+ }
64
+
65
+ /**
66
+ * Re-seals every row the old key opens under the new key, every workspace,
67
+ * one row at a time (a row is either re-sealed or untouched, never half).
68
+ * Rows neither key opens are returned by id and left as they are; a row the
69
+ * NEW key already opens is skipped, so running twice is safe.
70
+ */
71
+ export async function rotateConnectionsKey(exec: SqlExec, keys: { from: SealedKey; to: SealedKey }): Promise<{ resealed: number; unreadable: string[] }> {
72
+ const rows = await exec(`SELECT id, secret FROM connections ORDER BY created_at`)
73
+ let resealed = 0
74
+ const unreadable: string[] = []
75
+ for (const r of rows) {
76
+ const sealed = String(r.secret)
77
+ if (opens(keys.to, sealed)) continue
78
+ let value: unknown
79
+ try { value = openSecret(keys.from, sealed) } catch { unreadable.push(String(r.id)); continue }
80
+ await exec(`UPDATE connections SET secret = $2, updated_at = now() WHERE id = $1`, [r.id, sealSecret(keys.to, value)])
81
+ resealed++
82
+ }
83
+ return { resealed, unreadable }
84
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * File Name: skeleton.ts
3
+ * Purpose: Seeds the minimum set of records required to run the platform locally —
4
+ * a workspace, an admin member, and a full-access API key.
5
+ *
6
+ * Author: Raghunath J
7
+ * License: MIT
8
+ *
9
+ * Dependencies/Notes: Requires Bun runtime for password hashing (Bun.password.hash with
10
+ * argon2id). Run directly with `bun packages/db/src/seed/skeleton.ts` or import
11
+ * `seedSkeleton` programmatically. DATABASE_URL must be set in the environment.
12
+ */
13
+ import { createHash } from 'node:crypto'
14
+ import { createDb } from '../client'
15
+ import { workspaces, members, apiKeys } from '../schema'
16
+
17
+ /** Stable ID for the local-dev workspace inserted by this seed. */
18
+ export const SEED_WORKSPACE_ID = 'wsp_local_dev'
19
+ /** Email address of the seeded admin member; password is "password". */
20
+ export const SEED_ADMIN_EMAIL = 'admin@example.com'
21
+ /** Stable member ID for the seeded admin user. */
22
+ export const SEED_ADMIN_MEMBER_ID = 'mem_seed_admin_001'
23
+ /** Stable API key record ID for the seeded full-access key. */
24
+ export const SEED_API_KEY_ID = 'key_seed_admin_001'
25
+ /** Raw (unhashed) value of the local-dev API key — never use in production. */
26
+ export const SEED_RAW_API_KEY = 'tgn_dev_seed_local_dev_0000001'
27
+ /** First 12 characters of the raw key, stored in the DB for display purposes. */
28
+ export const SEED_API_KEY_PREFIX = SEED_RAW_API_KEY.slice(0, 12)
29
+
30
+ /**
31
+ * Inserts the minimal skeleton records needed to run the platform locally.
32
+ * All inserts use `onConflictDoNothing` so the function is safe to call repeatedly.
33
+ * @param db - Drizzle database client returned by `createDb`
34
+ */
35
+ export async function seedSkeleton(db: ReturnType<typeof createDb>) {
36
+ await db.insert(workspaces).values({
37
+ id: SEED_WORKSPACE_ID,
38
+ name: 'Local Dev',
39
+ slug: 'local-dev',
40
+ }).onConflictDoNothing()
41
+ console.log(`✓ workspace ${SEED_WORKSPACE_ID}`)
42
+
43
+ const passwordHash = await Bun.password.hash('password', {
44
+ algorithm: 'argon2id',
45
+ memoryCost: 19456,
46
+ timeCost: 2,
47
+ })
48
+ await db.insert(members).values({
49
+ id: SEED_ADMIN_MEMBER_ID,
50
+ workspaceId: SEED_WORKSPACE_ID,
51
+ email: SEED_ADMIN_EMAIL,
52
+ name: 'Admin',
53
+ passwordHash,
54
+ role: 'admin',
55
+ }).onConflictDoNothing()
56
+ console.log(`✓ member ${SEED_ADMIN_EMAIL} role=admin password=password`)
57
+
58
+ const keyHash = createHash('sha256').update(SEED_RAW_API_KEY).digest('hex')
59
+ await db.insert(apiKeys).values({
60
+ id: SEED_API_KEY_ID,
61
+ workspaceId: SEED_WORKSPACE_ID,
62
+ label: 'Local dev seed key (full-access)',
63
+ keyPrefix: SEED_API_KEY_PREFIX,
64
+ keyHash,
65
+ permissions: { admin: true },
66
+ }).onConflictDoNothing()
67
+ console.log(`✓ api_key ${SEED_API_KEY_PREFIX}... (full-access)`)
68
+ console.log(` raw key: ${SEED_RAW_API_KEY}`)
69
+ }
70
+
71
+ if (import.meta.main) {
72
+ const url = process.env.DATABASE_URL
73
+ if (!url) throw new Error('DATABASE_URL is not set')
74
+ const db = createDb(url)
75
+ await seedSkeleton(db)
76
+ process.exit(0)
77
+ }