@sapporta/server 0.0.1

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 (133) hide show
  1. package/package.json +40 -0
  2. package/src/actions/action.test.ts +108 -0
  3. package/src/actions/action.ts +60 -0
  4. package/src/actions/loader.ts +47 -0
  5. package/src/api/actions.ts +124 -0
  6. package/src/api/meta-mutations.ts +922 -0
  7. package/src/api/meta.ts +222 -0
  8. package/src/api/reports.ts +98 -0
  9. package/src/api/server.ts +24 -0
  10. package/src/api/tables.ts +108 -0
  11. package/src/api/views.ts +44 -0
  12. package/src/boot.ts +206 -0
  13. package/src/cli/ai-commands.ts +220 -0
  14. package/src/cli/check.ts +169 -0
  15. package/src/cli/cli-utils.test.ts +313 -0
  16. package/src/cli/describe.test.ts +151 -0
  17. package/src/cli/describe.ts +88 -0
  18. package/src/cli/emit-result.test.ts +160 -0
  19. package/src/cli/format.ts +150 -0
  20. package/src/cli/http-client.ts +55 -0
  21. package/src/cli/index.ts +162 -0
  22. package/src/cli/init.ts +35 -0
  23. package/src/cli/project-context.ts +38 -0
  24. package/src/cli/request.ts +146 -0
  25. package/src/cli/routes.ts +418 -0
  26. package/src/cli/rows-insert-master-detail.test.ts +124 -0
  27. package/src/cli/rows-insert-master-detail.ts +186 -0
  28. package/src/cli/rows-insert.test.ts +137 -0
  29. package/src/cli/rows-insert.ts +97 -0
  30. package/src/cli/serve-single.ts +49 -0
  31. package/src/create-project.ts +81 -0
  32. package/src/data/count.ts +62 -0
  33. package/src/data/crud.test.ts +188 -0
  34. package/src/data/crud.ts +242 -0
  35. package/src/data/lookup.test.ts +96 -0
  36. package/src/data/lookup.ts +104 -0
  37. package/src/data/query-parser.test.ts +67 -0
  38. package/src/data/query-parser.ts +106 -0
  39. package/src/data/sanitize.test.ts +57 -0
  40. package/src/data/sanitize.ts +25 -0
  41. package/src/data/save-pipeline.test.ts +115 -0
  42. package/src/data/save-pipeline.ts +93 -0
  43. package/src/data/validate.test.ts +110 -0
  44. package/src/data/validate.ts +98 -0
  45. package/src/db/errors.ts +20 -0
  46. package/src/db/logger.ts +63 -0
  47. package/src/db/sqlite-connection.test.ts +59 -0
  48. package/src/db/sqlite-connection.ts +79 -0
  49. package/src/index.ts +111 -0
  50. package/src/integration/api-actions.test.ts +60 -0
  51. package/src/integration/api-global.test.ts +21 -0
  52. package/src/integration/api-meta.test.ts +252 -0
  53. package/src/integration/api-reports.test.ts +77 -0
  54. package/src/integration/api-tables.test.ts +238 -0
  55. package/src/integration/api-views.test.ts +39 -0
  56. package/src/integration/cli-routes.test.ts +167 -0
  57. package/src/integration/fixtures/actions/create-account.ts +23 -0
  58. package/src/integration/fixtures/reports/account-list.ts +25 -0
  59. package/src/integration/fixtures/schema/accounts.ts +21 -0
  60. package/src/integration/fixtures/schema/audit-log.ts +19 -0
  61. package/src/integration/fixtures/schema/journal-entries.ts +20 -0
  62. package/src/integration/fixtures/views/dashboard.tsx +4 -0
  63. package/src/integration/fixtures/views/settings.tsx +3 -0
  64. package/src/integration/setup.ts +72 -0
  65. package/src/introspect/db-helpers.ts +109 -0
  66. package/src/introspect/describe-all.test.ts +73 -0
  67. package/src/introspect/describe-all.ts +80 -0
  68. package/src/introspect/describe.test.ts +65 -0
  69. package/src/introspect/describe.ts +184 -0
  70. package/src/introspect/exec.test.ts +103 -0
  71. package/src/introspect/exec.ts +57 -0
  72. package/src/introspect/indexes.test.ts +41 -0
  73. package/src/introspect/indexes.ts +95 -0
  74. package/src/introspect/inference.ts +98 -0
  75. package/src/introspect/list-tables.test.ts +40 -0
  76. package/src/introspect/list-tables.ts +62 -0
  77. package/src/introspect/query.test.ts +77 -0
  78. package/src/introspect/query.ts +47 -0
  79. package/src/introspect/sample.test.ts +67 -0
  80. package/src/introspect/sample.ts +50 -0
  81. package/src/introspect/sql-safety.ts +76 -0
  82. package/src/introspect/sqlite/db-helpers.test.ts +79 -0
  83. package/src/introspect/sqlite/db-helpers.ts +56 -0
  84. package/src/introspect/sqlite/describe-all.ts +21 -0
  85. package/src/introspect/sqlite/describe.test.ts +160 -0
  86. package/src/introspect/sqlite/describe.ts +185 -0
  87. package/src/introspect/sqlite/exec.ts +57 -0
  88. package/src/introspect/sqlite/indexes.test.ts +60 -0
  89. package/src/introspect/sqlite/indexes.ts +96 -0
  90. package/src/introspect/sqlite/list-tables.test.ts +100 -0
  91. package/src/introspect/sqlite/list-tables.ts +67 -0
  92. package/src/introspect/sqlite/query.ts +49 -0
  93. package/src/introspect/sqlite/sample.ts +50 -0
  94. package/src/introspect/table-rename.test.ts +235 -0
  95. package/src/introspect/table-rename.ts +115 -0
  96. package/src/introspect/types.ts +95 -0
  97. package/src/reports/check.test.ts +499 -0
  98. package/src/reports/check.ts +208 -0
  99. package/src/reports/engine.test.ts +1465 -0
  100. package/src/reports/engine.ts +678 -0
  101. package/src/reports/loader.ts +55 -0
  102. package/src/reports/report.ts +308 -0
  103. package/src/reports/sql-bind.ts +161 -0
  104. package/src/reports/sqlite-bind.test.ts +98 -0
  105. package/src/reports/sqlite-bind.ts +58 -0
  106. package/src/reports/sqlite-sql-client.ts +42 -0
  107. package/src/runtime.ts +3 -0
  108. package/src/schema/check.ts +90 -0
  109. package/src/schema/ddl.test.ts +210 -0
  110. package/src/schema/ddl.ts +180 -0
  111. package/src/schema/dynamic-builder.ts +297 -0
  112. package/src/schema/extract.test.ts +261 -0
  113. package/src/schema/extract.ts +285 -0
  114. package/src/schema/loader.test.ts +31 -0
  115. package/src/schema/loader.ts +60 -0
  116. package/src/schema/metadata-io.test.ts +261 -0
  117. package/src/schema/metadata-io.ts +161 -0
  118. package/src/schema/metadata-tables.test.ts +737 -0
  119. package/src/schema/metadata-tables.ts +341 -0
  120. package/src/schema/migrate.ts +195 -0
  121. package/src/schema/normalize-datatype.test.ts +58 -0
  122. package/src/schema/normalize-datatype.ts +99 -0
  123. package/src/schema/registry.test.ts +174 -0
  124. package/src/schema/registry.ts +139 -0
  125. package/src/schema/reserved.ts +227 -0
  126. package/src/schema/table.ts +135 -0
  127. package/src/test-fixtures/schema/accounts.ts +24 -0
  128. package/src/test-fixtures/schema/not-a-table.ts +6 -0
  129. package/src/testing/test-utils.ts +44 -0
  130. package/src/views/loader.test.ts +70 -0
  131. package/src/views/loader.ts +38 -0
  132. package/src/views/view.test.ts +121 -0
  133. package/src/views/view.ts +16 -0
@@ -0,0 +1,174 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
3
+ import { table } from "./table.js";
4
+ import { SchemaRegistry } from "./registry.js";
5
+
6
+ function makeTable(name: string) {
7
+ return table({
8
+ drizzle: sqliteTable(name, {
9
+ id: integer("id").primaryKey({ autoIncrement: true }),
10
+ name: text("name").notNull(),
11
+ }),
12
+ });
13
+ }
14
+
15
+ describe("SchemaRegistry", () => {
16
+ it("register and retrieve defs", () => {
17
+ const reg = new SchemaRegistry();
18
+ const a = makeTable("accounts");
19
+ const b = makeTable("invoices");
20
+ reg.register(a, "file");
21
+ reg.register(b, "file");
22
+
23
+ expect(reg.has("accounts")).toBe(true);
24
+ expect(reg.has("invoices")).toBe(true);
25
+ expect(reg.has("unknown")).toBe(false);
26
+ expect(reg.get("accounts")?.def).toBe(a);
27
+ expect(reg.get("accounts")?.source).toBe("file");
28
+ });
29
+
30
+ it("all() returns defs in insertion order", () => {
31
+ const reg = new SchemaRegistry();
32
+ const a = makeTable("alpha");
33
+ const b = makeTable("beta");
34
+ const c = makeTable("gamma");
35
+ reg.register(a, "file");
36
+ reg.register(b, "ui");
37
+ reg.register(c, "file");
38
+
39
+ const names = reg.all().map((d) => d.sqlName);
40
+ expect(names).toEqual(["alpha", "beta", "gamma"]);
41
+ });
42
+
43
+ it("unregister removes the entry", () => {
44
+ const reg = new SchemaRegistry();
45
+ reg.register(makeTable("accounts"), "file");
46
+ expect(reg.has("accounts")).toBe(true);
47
+
48
+ reg.unregister("accounts");
49
+ expect(reg.has("accounts")).toBe(false);
50
+ expect(reg.all()).toHaveLength(0);
51
+ });
52
+
53
+ it("unregister is a no-op for unknown names", () => {
54
+ const reg = new SchemaRegistry();
55
+ reg.unregister("nonexistent"); // should not throw
56
+ expect(reg.all()).toHaveLength(0);
57
+ });
58
+
59
+ it("file def shadows UI def with same name", () => {
60
+ const reg = new SchemaRegistry();
61
+ const fileDef = makeTable("accounts");
62
+ const uiDef = makeTable("accounts");
63
+
64
+ reg.register(fileDef, "file");
65
+ reg.register(uiDef, "ui");
66
+
67
+ // File def wins
68
+ expect(reg.get("accounts")?.def).toBe(fileDef);
69
+ expect(reg.get("accounts")?.source).toBe("file");
70
+
71
+ // UI def is shadowed
72
+ expect(reg.isShadowed("accounts")).toBe(true);
73
+ expect(reg.shadowedEntries().get("accounts")).toBe(uiDef);
74
+
75
+ // Only one entry in all()
76
+ expect(reg.all()).toHaveLength(1);
77
+ });
78
+
79
+ it("file def takes over from existing UI def", () => {
80
+ const reg = new SchemaRegistry();
81
+ const uiDef = makeTable("accounts");
82
+ const fileDef = makeTable("accounts");
83
+
84
+ reg.register(uiDef, "ui");
85
+ reg.register(fileDef, "file");
86
+
87
+ // File def wins
88
+ expect(reg.get("accounts")?.def).toBe(fileDef);
89
+ expect(reg.get("accounts")?.source).toBe("file");
90
+
91
+ // Original UI def is now shadowed
92
+ expect(reg.isShadowed("accounts")).toBe(true);
93
+ expect(reg.shadowedEntries().get("accounts")).toBe(uiDef);
94
+ });
95
+
96
+ it("fileManaged and uiManaged return correct subsets", () => {
97
+ const reg = new SchemaRegistry();
98
+ reg.register(makeTable("accounts"), "file");
99
+ reg.register(makeTable("invoices"), "ui");
100
+ reg.register(makeTable("products"), "file");
101
+
102
+ expect(reg.fileManaged().map((d) => d.sqlName)).toEqual(["accounts", "products"]);
103
+ expect(reg.uiManaged().map((d) => d.sqlName)).toEqual(["invoices"]);
104
+ });
105
+
106
+ it("onChange fires on register and unregister", () => {
107
+ const reg = new SchemaRegistry();
108
+ const listener = vi.fn();
109
+ reg.onChange(listener);
110
+
111
+ reg.register(makeTable("accounts"), "file");
112
+ expect(listener).toHaveBeenCalledWith("accounts");
113
+ expect(listener).toHaveBeenCalledTimes(1);
114
+
115
+ reg.unregister("accounts");
116
+ expect(listener).toHaveBeenCalledWith("accounts");
117
+ expect(listener).toHaveBeenCalledTimes(2);
118
+ });
119
+
120
+ it("onChange does not fire when UI def is shadowed (no runtime change)", () => {
121
+ const reg = new SchemaRegistry();
122
+ reg.register(makeTable("accounts"), "file");
123
+
124
+ const listener = vi.fn();
125
+ reg.onChange(listener);
126
+
127
+ // Registering a UI def that gets shadowed should not notify
128
+ reg.register(makeTable("accounts"), "ui");
129
+ expect(listener).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it("re-registering same name replaces the def", () => {
133
+ const reg = new SchemaRegistry();
134
+ const v1 = makeTable("accounts");
135
+ const v2 = makeTable("accounts");
136
+ reg.register(v1, "file");
137
+ reg.register(v2, "file");
138
+
139
+ expect(reg.get("accounts")?.def).toBe(v2);
140
+ // Should still be only one entry
141
+ expect(reg.all()).toHaveLength(1);
142
+ });
143
+
144
+ it("allEntries returns entries with source tags", () => {
145
+ const reg = new SchemaRegistry();
146
+ reg.register(makeTable("a"), "file");
147
+ reg.register(makeTable("b"), "ui");
148
+
149
+ const entries = reg.allEntries();
150
+ expect(entries).toHaveLength(2);
151
+ expect(entries[0].source).toBe("file");
152
+ expect(entries[1].source).toBe("ui");
153
+ });
154
+
155
+ it("unregistering file def does NOT promote shadowed UI def", () => {
156
+ // The shadow is discarded alongside the file entry. The boot sequence
157
+ // reloads UI schemas from metadata tables, so promoting a stale
158
+ // in-memory copy would be incorrect.
159
+ const reg = new SchemaRegistry();
160
+ const uiDef = makeTable("accounts");
161
+ const fileDef = makeTable("accounts");
162
+
163
+ reg.register(uiDef, "ui");
164
+ reg.register(fileDef, "file");
165
+ expect(reg.isShadowed("accounts")).toBe(true);
166
+
167
+ reg.unregister("accounts");
168
+
169
+ // Both the file entry AND the shadowed UI def are gone
170
+ expect(reg.has("accounts")).toBe(false);
171
+ expect(reg.isShadowed("accounts")).toBe(false);
172
+ expect(reg.all()).toHaveLength(0);
173
+ });
174
+ });
@@ -0,0 +1,139 @@
1
+ import type { TableDef } from "./table.js";
2
+
3
+ export interface RegistryEntry {
4
+ def: TableDef;
5
+ /** "file" = defined in TypeScript schema files (always authoritative).
6
+ * "ui" = created at runtime via the metadata tables. */
7
+ source: "file" | "ui";
8
+ }
9
+
10
+ /**
11
+ * Mutable, in-memory registry of table schemas shared across a project's runtime.
12
+ * The dynamic router queries this on each request to resolve table names to schemas,
13
+ * so tables added at runtime are immediately accessible without a restart.
14
+ *
15
+ * ## Merge semantics
16
+ *
17
+ * During boot the registry is populated in two phases:
18
+ * 1. File schemas are registered first (from `loadSchemas()`)
19
+ * 2. UI schemas are registered second (from `_sapporta_tables` metadata)
20
+ *
21
+ * **File always wins.** If a UI-managed table has the same name as a file-managed
22
+ * one, the UI definition is "shadowed" — tracked separately so the Schema API can
23
+ * warn the user, but the file definition is used at runtime. This prevents
24
+ * UI-created tables from accidentally overriding TypeScript schemas.
25
+ *
26
+ * ## Ordering
27
+ *
28
+ * Stable insertion order is maintained for sidebar display. File-managed tables
29
+ * keep their load order; UI-managed tables are ordered by position/created_at.
30
+ */
31
+ export class SchemaRegistry {
32
+ private entries = new Map<string, RegistryEntry>();
33
+
34
+ /** Separate from entries because re-registering a name must keep its position. */
35
+ private order: string[] = [];
36
+
37
+ /** UI defs hidden by a file def with the same name. Kept for Schema API warnings. */
38
+ private shadowedDefs = new Map<string, TableDef>();
39
+
40
+ private listeners: Array<(name: string) => void> = [];
41
+
42
+ /**
43
+ * Register a table definition. File definitions always take precedence:
44
+ * - File exists + UI registering → UI def is shadowed (not registered)
45
+ * - UI exists + File registering → File takes over, UI def moves to shadow
46
+ * - Same source re-registering → replaced in-place (for schema rebuilds)
47
+ */
48
+ register(def: TableDef, source: "file" | "ui"): void {
49
+ const name = def.sqlName;
50
+ const existing = this.entries.get(name);
51
+
52
+ // File is authoritative — shadow the UI def, don't notify (no runtime change).
53
+ if (existing && existing.source === "file" && source === "ui") {
54
+ this.shadowedDefs.set(name, def);
55
+ return;
56
+ }
57
+
58
+ // File takes over from UI — move the existing UI def to the shadow map.
59
+ if (existing && existing.source === "ui" && source === "file") {
60
+ this.shadowedDefs.set(name, existing.def);
61
+ }
62
+
63
+ this.entries.set(name, { def, source });
64
+
65
+ // Re-registrations keep their original position in the ordering.
66
+ if (!this.order.includes(name)) {
67
+ this.order.push(name);
68
+ }
69
+ this.notify(name);
70
+ }
71
+
72
+ /** Remove a table from the registry (e.g. when drift validation finds the DB table is missing). */
73
+ unregister(name: string): void {
74
+ if (this.entries.delete(name)) {
75
+ this.order = this.order.filter((n) => n !== name);
76
+ this.shadowedDefs.delete(name);
77
+ this.notify(name);
78
+ }
79
+ }
80
+
81
+ /** Look up a registry entry by table name. */
82
+ get(name: string): RegistryEntry | undefined {
83
+ return this.entries.get(name);
84
+ }
85
+
86
+ /** Check if a table name is registered (active, not just shadowed). */
87
+ has(name: string): boolean {
88
+ return this.entries.has(name);
89
+ }
90
+
91
+ /** All active table definitions, in stable insertion order. */
92
+ all(): TableDef[] {
93
+ return this.order
94
+ .filter((name) => this.entries.has(name))
95
+ .map((name) => this.entries.get(name)!.def);
96
+ }
97
+
98
+ /** All entries with source tags, in stable insertion order. */
99
+ allEntries(): RegistryEntry[] {
100
+ return this.order
101
+ .filter((name) => this.entries.has(name))
102
+ .map((name) => this.entries.get(name)!);
103
+ }
104
+
105
+ /** Only file-sourced table definitions. */
106
+ fileManaged(): TableDef[] {
107
+ return this.allEntries()
108
+ .filter((e) => e.source === "file")
109
+ .map((e) => e.def);
110
+ }
111
+
112
+ /** Only UI-sourced table definitions. */
113
+ uiManaged(): TableDef[] {
114
+ return this.allEntries()
115
+ .filter((e) => e.source === "ui")
116
+ .map((e) => e.def);
117
+ }
118
+
119
+ /** UI definitions shadowed by file definitions. Returns a copy of the map. */
120
+ shadowedEntries(): Map<string, TableDef> {
121
+ return new Map(this.shadowedDefs);
122
+ }
123
+
124
+ /** Check if a table name has a shadowed UI definition. */
125
+ isShadowed(name: string): boolean {
126
+ return this.shadowedDefs.has(name);
127
+ }
128
+
129
+ /** Subscribe to register/unregister events. Listener receives the table name. */
130
+ onChange(listener: (name: string) => void): void {
131
+ this.listeners.push(listener);
132
+ }
133
+
134
+ private notify(name: string): void {
135
+ for (const listener of this.listeners) {
136
+ listener(name);
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Reserved identifiers that cannot be used as table names.
3
+ * These collide with static route segments mounted before the dynamic CRUD handler.
4
+ */
5
+ const RESERVED_TABLE_NAMES = new Set([
6
+ // Static API route segments
7
+ "_schema",
8
+ "_meta",
9
+ "_reports",
10
+ "_forms",
11
+ "actions",
12
+ // Per-table sub-path segments (used in /:tableName/_lookup etc.)
13
+ "_lookup",
14
+ "_count",
15
+ ]);
16
+
17
+ /**
18
+ * Reserved prefixes. Any name starting with these is rejected.
19
+ */
20
+ const RESERVED_PREFIXES = ["_sapporta_"];
21
+
22
+ /**
23
+ * Validate a SQL table name for use as a UI-managed table.
24
+ *
25
+ * Rules:
26
+ * - Must match [a-z][a-z0-9_]* (snake_case, starts with letter)
27
+ * - Must not be a reserved route segment
28
+ * - Must not start with a reserved prefix
29
+ * - Must not be a SQLite reserved keyword
30
+ */
31
+ export function validateTableName(
32
+ name: string,
33
+ ): { valid: true } | { valid: false; reason: string } {
34
+ if (!/^[a-z][a-z0-9_]*$/.test(name)) {
35
+ return {
36
+ valid: false,
37
+ reason: "Must be lowercase snake_case starting with a letter",
38
+ };
39
+ }
40
+ if (RESERVED_TABLE_NAMES.has(name)) {
41
+ return { valid: false, reason: `"${name}" is a reserved system name` };
42
+ }
43
+ for (const prefix of RESERVED_PREFIXES) {
44
+ if (name.startsWith(prefix)) {
45
+ return {
46
+ valid: false,
47
+ reason: `Names starting with "${prefix}" are reserved for internal use`,
48
+ };
49
+ }
50
+ }
51
+ if (SQLITE_RESERVED_WORDS.has(name)) {
52
+ return { valid: false, reason: `"${name}" is a SQLite reserved word` };
53
+ }
54
+ return { valid: true };
55
+ }
56
+
57
+ /**
58
+ * Validate a SQL column name.
59
+ */
60
+ export function validateColumnName(
61
+ name: string,
62
+ ): { valid: true } | { valid: false; reason: string } {
63
+ if (!/^[a-z][a-z0-9_]*$/.test(name)) {
64
+ return {
65
+ valid: false,
66
+ reason: "Must be lowercase snake_case starting with a letter",
67
+ };
68
+ }
69
+ if (name === "id" || name === "created_at" || name === "updated_at") {
70
+ return {
71
+ valid: false,
72
+ reason: `"${name}" is auto-managed and cannot be added manually`,
73
+ };
74
+ }
75
+ return { valid: true };
76
+ }
77
+
78
+ /** SQLite reserved keywords from https://www.sqlite.org/lang_keywords.html */
79
+ const SQLITE_RESERVED_WORDS = new Set([
80
+ "abort",
81
+ "action",
82
+ "add",
83
+ "after",
84
+ "all",
85
+ "alter",
86
+ "always",
87
+ "analyze",
88
+ "and",
89
+ "as",
90
+ "asc",
91
+ "attach",
92
+ "autoincrement",
93
+ "before",
94
+ "begin",
95
+ "between",
96
+ "by",
97
+ "cascade",
98
+ "case",
99
+ "cast",
100
+ "check",
101
+ "collate",
102
+ "column",
103
+ "commit",
104
+ "conflict",
105
+ "constraint",
106
+ "create",
107
+ "cross",
108
+ "current",
109
+ "current_date",
110
+ "current_time",
111
+ "current_timestamp",
112
+ "database",
113
+ "default",
114
+ "deferrable",
115
+ "deferred",
116
+ "delete",
117
+ "desc",
118
+ "detach",
119
+ "distinct",
120
+ "do",
121
+ "drop",
122
+ "each",
123
+ "else",
124
+ "end",
125
+ "escape",
126
+ "except",
127
+ "exclude",
128
+ "exclusive",
129
+ "exists",
130
+ "explain",
131
+ "fail",
132
+ "filter",
133
+ "first",
134
+ "following",
135
+ "for",
136
+ "foreign",
137
+ "from",
138
+ "full",
139
+ "generated",
140
+ "glob",
141
+ "group",
142
+ "groups",
143
+ "having",
144
+ "if",
145
+ "ignore",
146
+ "immediate",
147
+ "in",
148
+ "index",
149
+ "indexed",
150
+ "initially",
151
+ "inner",
152
+ "insert",
153
+ "instead",
154
+ "intersect",
155
+ "into",
156
+ "is",
157
+ "isnull",
158
+ "join",
159
+ "key",
160
+ "last",
161
+ "left",
162
+ "like",
163
+ "limit",
164
+ "match",
165
+ "materialized",
166
+ "natural",
167
+ "no",
168
+ "not",
169
+ "nothing",
170
+ "notnull",
171
+ "null",
172
+ "nulls",
173
+ "of",
174
+ "offset",
175
+ "on",
176
+ "or",
177
+ "order",
178
+ "others",
179
+ "outer",
180
+ "over",
181
+ "partition",
182
+ "plan",
183
+ "pragma",
184
+ "preceding",
185
+ "primary",
186
+ "query",
187
+ "raise",
188
+ "range",
189
+ "recursive",
190
+ "references",
191
+ "regexp",
192
+ "reindex",
193
+ "release",
194
+ "rename",
195
+ "replace",
196
+ "restrict",
197
+ "returning",
198
+ "right",
199
+ "rollback",
200
+ "row",
201
+ "rows",
202
+ "savepoint",
203
+ "select",
204
+ "set",
205
+ "table",
206
+ "temp",
207
+ "temporary",
208
+ "then",
209
+ "ties",
210
+ "to",
211
+ "transaction",
212
+ "trigger",
213
+ "unbounded",
214
+ "union",
215
+ "unique",
216
+ "update",
217
+ "using",
218
+ "vacuum",
219
+ "values",
220
+ "view",
221
+ "virtual",
222
+ "when",
223
+ "where",
224
+ "window",
225
+ "with",
226
+ "without",
227
+ ]);
@@ -0,0 +1,135 @@
1
+ import { type SQLiteTableWithColumns, getTableConfig } from "drizzle-orm/sqlite-core";
2
+ import { text } from "drizzle-orm/sqlite-core";
3
+ import type { z } from "zod";
4
+
5
+ /**
6
+ * Pre-configured timestamp column for Sapporta schemas.
7
+ *
8
+ * SQLite has no native timestamp type. We use TEXT columns that store
9
+ * ISO 8601 strings (e.g. "2024-01-15T10:30:00.000Z"). This matches
10
+ * Sapporta's JSON-over-HTTP data flow — dates cross every boundary as
11
+ * strings, so storing them as text avoids any Date↔string conversion.
12
+ *
13
+ * Callers can chain:
14
+ * - .$defaultFn(() => new Date().toISOString()) — JS-side default
15
+ * - .default(sql`(datetime('now'))`) — DDL-side default
16
+ *
17
+ * Usage in schema files:
18
+ * ```ts
19
+ * import { timestamp } from "@sapporta/server/table";
20
+ * eaten_at: timestamp("eaten_at").notNull().$defaultFn(() => new Date().toISOString()),
21
+ * ```
22
+ */
23
+ export function timestamp(name: string) {
24
+ return text(name);
25
+ }
26
+
27
+ /** Metadata for a select/enum column */
28
+ export interface SelectMeta {
29
+ type: "select";
30
+ column: string;
31
+ options: string[];
32
+ }
33
+
34
+ /** Declares a has-many child relationship for grid display */
35
+ export interface ChildMeta {
36
+ /** SQL name of the child table */
37
+ table: string;
38
+ /** FK column in the child table that references this parent's PK */
39
+ foreignKey: string;
40
+ /** Display label (defaults to child table's label) */
41
+ label?: string;
42
+ /** Columns to show in nested grid (defaults to all non-PK, non-FK, non-timestamp cols) */
43
+ columns?: string[];
44
+ /** Default sort: "column" or "-column" (defaults to PK asc) */
45
+ defaultSort?: string;
46
+ /** Width hint in approximate character count (same as ColumnMeta.width) */
47
+ width?: number;
48
+ }
49
+
50
+ /** Per-column metadata for display and behavior */
51
+ export interface ColumnMeta {
52
+ /** Display type override (e.g. "money" for right-aligned decimal formatting) */
53
+ type?: "money";
54
+ /** Logical column type from UI metadata (e.g. "text", "email", "link", "currency").
55
+ * Only set for UI-managed tables. File-managed tables don't have this. */
56
+ columnType?: string;
57
+ /** Custom column header for display */
58
+ header?: string;
59
+ /** Hide column from grid and drawer (auto-set for created_at/updated_at) */
60
+ hidden?: boolean;
61
+ /** Width hint in approximate character count */
62
+ width?: number;
63
+ /** Minimum width in approximate character count */
64
+ minWidth?: number;
65
+ /** Maximum width in approximate character count */
66
+ maxWidth?: number;
67
+ /** Pixel width from user drag-resize (UI-managed tables only, not for schema-as-code) */
68
+ widthPx?: number;
69
+ /** Set to false for nullable numeric columns where NULL is semantically distinct
70
+ from 0 (e.g. an optional assertion value). Suppresses the nullable-numeric
71
+ checker warning. */
72
+ additive?: boolean;
73
+ /** Freeform notes describing the column's meaning, conventions, or formula */
74
+ notes?: string;
75
+ }
76
+
77
+ /** Sapporta-specific metadata attached to a table */
78
+ export interface SapportaMeta {
79
+ /** Display label for the table */
80
+ label?: string;
81
+ /** Column used as the display value in lookups and FK references */
82
+ displayColumn?: string;
83
+ /** Select/enum columns */
84
+ selects?: SelectMeta[];
85
+ /** Whether records are immutable (no update/delete) */
86
+ immutable?: boolean;
87
+ /** Whether LLM inference has been run on this table (UI-managed tables only) */
88
+ inferred?: boolean;
89
+ /** User-provided Zod validation schema (overrides auto-inferred) */
90
+ validation?: z.ZodObject<any>;
91
+ /** Custom save function (overrides default insert/update) */
92
+ save?: (record: Record<string, unknown>, db: any) => Promise<any>;
93
+ /** Has-many child relationships for nested grid display */
94
+ children?: ChildMeta[];
95
+ /** Per-column metadata keyed by column name */
96
+ columns?: Record<string, ColumnMeta>;
97
+ }
98
+
99
+ /** A Sapporta table definition — wraps a Drizzle SQLite table with metadata */
100
+ export interface TableDef {
101
+ /** The Drizzle SQLite table object */
102
+ drizzle: SQLiteTableWithColumns<any>;
103
+ /** SQL table name extracted from the Drizzle table */
104
+ sqlName: string;
105
+ /** Sapporta metadata */
106
+ meta: SapportaMeta;
107
+ }
108
+
109
+ /** Options for the table() function */
110
+ export interface TableOptions {
111
+ /** The Drizzle sqliteTable definition */
112
+ drizzle: SQLiteTableWithColumns<any>;
113
+ /** Optional sapporta metadata */
114
+ meta?: SapportaMeta;
115
+ }
116
+
117
+ /**
118
+ * Define a Sapporta table. Wraps a Drizzle sqliteTable with metadata.
119
+ *
120
+ * Usage:
121
+ * ```ts
122
+ * const accounts = table({
123
+ * drizzle: sqliteTable("accounts", { ... }),
124
+ * meta: { label: "Accounts" }
125
+ * });
126
+ * ```
127
+ */
128
+ export function table(options: TableOptions): TableDef {
129
+ const config = getTableConfig(options.drizzle);
130
+ return {
131
+ drizzle: options.drizzle,
132
+ sqlName: config.name,
133
+ meta: options.meta ?? {},
134
+ };
135
+ }
@@ -0,0 +1,24 @@
1
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
2
+ import { table } from "../../schema/table.js";
3
+
4
+ // SQLite has no native enum type. Enum values are expressed as
5
+ // text({ enum: [...] }) directly in the column definition.
6
+ export default table({
7
+ drizzle: sqliteTable("accounts", {
8
+ id: integer("id").primaryKey({ autoIncrement: true }),
9
+ name: text("name").notNull(),
10
+ account_type: text("account_type", {
11
+ enum: ["Asset", "Liability", "Equity", "Revenue", "Expense"],
12
+ }).notNull(),
13
+ }),
14
+ meta: {
15
+ label: "Accounts",
16
+ selects: [
17
+ {
18
+ type: "select",
19
+ column: "account_type",
20
+ options: ["Asset", "Liability", "Equity", "Revenue", "Expense"],
21
+ },
22
+ ],
23
+ },
24
+ });
@@ -0,0 +1,6 @@
1
+ // This file exports things that are NOT TableDefs.
2
+ // loadSchemas should skip them without throwing.
3
+
4
+ export const someConfig = { host: "localhost", port: 5432 };
5
+ export function helper() { return 42; }
6
+ export default "not a table";