@supatype/cli 0.2.1 → 0.3.0

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 (162) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +180 -143
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/api-config-cache.d.ts +34 -0
  5. package/dist/api-config-cache.d.ts.map +1 -0
  6. package/dist/api-config-cache.js +123 -0
  7. package/dist/api-config-cache.js.map +1 -0
  8. package/dist/cache-identity-scope.d.ts +38 -0
  9. package/dist/cache-identity-scope.d.ts.map +1 -0
  10. package/dist/cache-identity-scope.js +70 -0
  11. package/dist/cache-identity-scope.js.map +1 -0
  12. package/dist/cache-provider.d.ts +30 -0
  13. package/dist/cache-provider.d.ts.map +1 -0
  14. package/dist/cache-provider.js +60 -0
  15. package/dist/cache-provider.js.map +1 -0
  16. package/dist/cli-version-embedded.js +1 -1
  17. package/dist/commands/add.js +1 -1
  18. package/dist/commands/add.js.map +1 -1
  19. package/dist/commands/cache.d.ts +1 -1
  20. package/dist/commands/cache.js +3 -3
  21. package/dist/commands/cache.js.map +1 -1
  22. package/dist/commands/cloud.d.ts.map +1 -1
  23. package/dist/commands/cloud.js +2 -1
  24. package/dist/commands/cloud.js.map +1 -1
  25. package/dist/commands/deploy.d.ts.map +1 -1
  26. package/dist/commands/deploy.js +2 -1
  27. package/dist/commands/deploy.js.map +1 -1
  28. package/dist/commands/dev.d.ts.map +1 -1
  29. package/dist/commands/dev.js +61 -8
  30. package/dist/commands/dev.js.map +1 -1
  31. package/dist/commands/diff.d.ts.map +1 -1
  32. package/dist/commands/diff.js +2 -1
  33. package/dist/commands/diff.js.map +1 -1
  34. package/dist/commands/functions.d.ts.map +1 -1
  35. package/dist/commands/functions.js +6 -2
  36. package/dist/commands/functions.js.map +1 -1
  37. package/dist/commands/init.d.ts.map +1 -1
  38. package/dist/commands/init.js +13 -9
  39. package/dist/commands/init.js.map +1 -1
  40. package/dist/commands/migrate.d.ts.map +1 -1
  41. package/dist/commands/migrate.js +2 -1
  42. package/dist/commands/migrate.js.map +1 -1
  43. package/dist/commands/push.d.ts.map +1 -1
  44. package/dist/commands/push.js +52 -1
  45. package/dist/commands/push.js.map +1 -1
  46. package/dist/compose-services.d.ts.map +1 -1
  47. package/dist/compose-services.js +2 -1
  48. package/dist/compose-services.js.map +1 -1
  49. package/dist/dev-compose.d.ts.map +1 -1
  50. package/dist/dev-compose.js +18 -7
  51. package/dist/dev-compose.js.map +1 -1
  52. package/dist/engine-floor.d.ts +11 -1
  53. package/dist/engine-floor.d.ts.map +1 -1
  54. package/dist/engine-floor.js +49 -16
  55. package/dist/engine-floor.js.map +1 -1
  56. package/dist/functions-deno-types.d.ts +9 -0
  57. package/dist/functions-deno-types.d.ts.map +1 -1
  58. package/dist/functions-deno-types.js +45 -1
  59. package/dist/functions-deno-types.js.map +1 -1
  60. package/dist/functions-router-gen.d.ts.map +1 -1
  61. package/dist/functions-router-gen.js +113 -81
  62. package/dist/functions-router-gen.js.map +1 -1
  63. package/dist/kong-config.d.ts +1 -1
  64. package/dist/model-cache.d.ts +17 -0
  65. package/dist/model-cache.d.ts.map +1 -0
  66. package/dist/model-cache.js +45 -0
  67. package/dist/model-cache.js.map +1 -0
  68. package/dist/model-hooks.d.ts +5 -0
  69. package/dist/model-hooks.d.ts.map +1 -1
  70. package/dist/model-hooks.js +29 -1
  71. package/dist/model-hooks.js.map +1 -1
  72. package/dist/model-versioning.d.ts +80 -0
  73. package/dist/model-versioning.d.ts.map +1 -0
  74. package/dist/model-versioning.js +140 -0
  75. package/dist/model-versioning.js.map +1 -0
  76. package/dist/postgres-ctl.d.ts +55 -0
  77. package/dist/postgres-ctl.d.ts.map +1 -1
  78. package/dist/postgres-ctl.js +84 -8
  79. package/dist/postgres-ctl.js.map +1 -1
  80. package/dist/preview-config-check.d.ts +21 -0
  81. package/dist/preview-config-check.d.ts.map +1 -0
  82. package/dist/preview-config-check.js +47 -0
  83. package/dist/preview-config-check.js.map +1 -0
  84. package/dist/project-config.d.ts +182 -5
  85. package/dist/project-config.d.ts.map +1 -1
  86. package/dist/project-config.js +125 -8
  87. package/dist/project-config.js.map +1 -1
  88. package/dist/resolve-target.d.ts +9 -0
  89. package/dist/resolve-target.d.ts.map +1 -1
  90. package/dist/resolve-target.js.map +1 -1
  91. package/dist/rest-cache-admin.d.ts.map +1 -1
  92. package/dist/rest-cache-admin.js +6 -2
  93. package/dist/rest-cache-admin.js.map +1 -1
  94. package/dist/schema-ast-v2.d.ts +32 -1
  95. package/dist/schema-ast-v2.d.ts.map +1 -1
  96. package/dist/schema-ast-v2.js +14 -1
  97. package/dist/schema-ast-v2.js.map +1 -1
  98. package/dist/self-host-compose.d.ts +20 -1
  99. package/dist/self-host-compose.d.ts.map +1 -1
  100. package/dist/self-host-compose.js +181 -27
  101. package/dist/self-host-compose.js.map +1 -1
  102. package/dist/studio-admin-roles.d.ts +8 -1
  103. package/dist/studio-admin-roles.d.ts.map +1 -1
  104. package/dist/studio-admin-roles.js +16 -2
  105. package/dist/studio-admin-roles.js.map +1 -1
  106. package/dist/type-extractor.d.ts +7 -0
  107. package/dist/type-extractor.d.ts.map +1 -1
  108. package/dist/type-extractor.js +234 -3
  109. package/dist/type-extractor.js.map +1 -1
  110. package/package.json +2 -2
  111. package/src/api-config-cache.ts +142 -0
  112. package/src/cache-identity-scope.ts +92 -0
  113. package/src/cache-provider.ts +61 -0
  114. package/src/cli-version-embedded.ts +1 -1
  115. package/src/commands/add.ts +1 -1
  116. package/src/commands/cache.ts +3 -3
  117. package/src/commands/cloud.ts +2 -1
  118. package/src/commands/deploy.ts +2 -1
  119. package/src/commands/dev.ts +68 -7
  120. package/src/commands/diff.ts +2 -1
  121. package/src/commands/functions.ts +6 -2
  122. package/src/commands/init.ts +13 -9
  123. package/src/commands/migrate.ts +2 -1
  124. package/src/commands/push.ts +57 -1
  125. package/src/compose-services.ts +2 -1
  126. package/src/dev-compose.ts +18 -7
  127. package/src/engine-floor.ts +73 -16
  128. package/src/functions-deno-types.ts +48 -1
  129. package/src/functions-router-gen.ts +113 -81
  130. package/src/kong-config.ts +1 -1
  131. package/src/model-cache.ts +74 -0
  132. package/src/model-hooks.ts +26 -2
  133. package/src/model-versioning.ts +167 -0
  134. package/src/postgres-ctl.ts +103 -9
  135. package/src/preview-config-check.ts +52 -0
  136. package/src/project-config.ts +273 -11
  137. package/src/resolve-target.ts +11 -1
  138. package/src/rest-cache-admin.ts +8 -2
  139. package/src/schema-ast-v2.ts +47 -0
  140. package/src/self-host-compose.ts +204 -27
  141. package/src/studio-admin-roles.ts +16 -2
  142. package/src/type-extractor.ts +281 -2
  143. package/tests/api-config-cache-seed.test.ts +180 -0
  144. package/tests/cache-identity-scope.test.ts +61 -0
  145. package/tests/cache-provider-config.test.ts +132 -0
  146. package/tests/engine-floor.test.ts +43 -0
  147. package/tests/external-database-compose.test.ts +74 -3
  148. package/tests/field-masking-tier.test.ts +3 -3
  149. package/tests/fixtures/identity-scope-corpus.json +42 -0
  150. package/tests/functions-concurrency.test.ts +71 -0
  151. package/tests/init.test.ts +36 -2
  152. package/tests/model-cache-declaration.test.ts +229 -0
  153. package/tests/model-cache-manifest.test.ts +99 -0
  154. package/tests/model-hooks.test.ts +55 -0
  155. package/tests/model-versions.test.ts +229 -0
  156. package/tests/native-keyspace.test.ts +130 -0
  157. package/tests/preview-config-check.test.ts +104 -0
  158. package/tests/rest-cache-admin.test.ts +36 -0
  159. package/tests/runtime-contract.test.ts +176 -22
  160. package/tests/studio-admin-roles.test.ts +32 -0
  161. package/tests/type-extractor.test.ts +113 -0
  162. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,229 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { afterEach, describe, expect, it } from "vitest"
5
+ import { extractSchemaAstFromTypes } from "../src/type-extractor.js"
6
+
7
+ // `cache` is declared on the model because two of its four settings cannot be checked anywhere
8
+ // else. `public` is a data-leak switch whose safety depends on `access.read`, which is right there
9
+ // in the same object; `rows` depends on there being a primary key. A Studio checkbox can see
10
+ // neither, which is the argument for the declaration existing at all (plan §13.3).
11
+
12
+ const dirs: string[] = []
13
+
14
+ afterEach(() => {
15
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
16
+ })
17
+
18
+ function extract(body: string): ReturnType<typeof extractSchemaAstFromTypes> {
19
+ const dir = mkdtempSync(join(tmpdir(), "supatype-cache-"))
20
+ dirs.push(dir)
21
+ const schemaPath = join(dir, "schema.ts")
22
+ writeFileSync(
23
+ schemaPath,
24
+ `
25
+ import type {
26
+ AuthUid, DateTime, Eq, LoggedIn, Lte, Model, Now, Optional, Owner, Public, Role, UUID,
27
+ } from "@supatype/types"
28
+
29
+ ${body}
30
+ `,
31
+ "utf8",
32
+ )
33
+ return extractSchemaAstFromTypes(schemaPath, dir)
34
+ }
35
+
36
+ const cacheOf = (ast: ReturnType<typeof extractSchemaAstFromTypes>): unknown =>
37
+ ast?.models[0]?.annotations.platform.cache
38
+
39
+ describe("the declaration itself", () => {
40
+ it("carries all four settings through", () => {
41
+ const ast = extract(`
42
+ export type Post = Model<{
43
+ id: UUID
44
+ title: Slug
45
+ }, {
46
+ access: { read: Public }
47
+ cache: { enabled: true; maxTtl: 60; public: true; rows: true }
48
+ }>`)
49
+ expect(cacheOf(ast)).toEqual({ enabled: true, maxTtl: 60, public: true, rows: true })
50
+ })
51
+
52
+ it("is absent when the model declares none, which is what makes it uncacheable", () => {
53
+ const ast = extract(`
54
+ export type Post = Model<{ id: UUID; title: Slug }, { access: { read: Public } }>`)
55
+ expect(cacheOf(ast)).toBeUndefined()
56
+ })
57
+
58
+ it("treats an empty block as no declaration rather than an empty ceiling", () => {
59
+ // "Declared but permitting nothing" reads the same as saying nothing, and an empty entry in
60
+ // every manifest is noise a reader has to interpret.
61
+ const ast = extract(`
62
+ export type Post = Model<{ id: UUID; title: Slug }, { access: { read: Public }; cache: {} }>`)
63
+ expect(cacheOf(ast)).toBeUndefined()
64
+ })
65
+
66
+ it("keeps an explicit false, because it is a hard opt-out and not a default", () => {
67
+ const ast = extract(`
68
+ export type Post = Model<{ id: UUID; title: Slug }, {
69
+ access: { read: Public }
70
+ cache: { enabled: false }
71
+ }>`)
72
+ expect(cacheOf(ast)).toEqual({ enabled: false })
73
+ })
74
+ })
75
+
76
+ describe("public caching on a caller-varying table", () => {
77
+ it("is refused, naming the model", () => {
78
+ // The check §13.3 calls worth the feature on its own. Nothing else in the system can see both
79
+ // the cache setting and the read rule at once.
80
+ expect(() =>
81
+ extract(`
82
+ export type Post = Model<{ id: UUID; user_id: UUID; title: Slug }, {
83
+ access: { read: Owner<"user_id"> }
84
+ cache: { enabled: true; public: true }
85
+ }>`),
86
+ ).toThrow(/cache\.public.*varies by caller|varies by caller.*cache\.public/s)
87
+ })
88
+
89
+ it("is refused for LoggedIn, which still separates members from strangers", () => {
90
+ expect(() =>
91
+ extract(`
92
+ export type Post = Model<{ id: UUID; title: Slug }, {
93
+ access: { read: LoggedIn }
94
+ cache: { enabled: true; public: true }
95
+ }>`),
96
+ ).toThrow(/cache\.public/)
97
+ })
98
+
99
+ it("is refused when the rule compares a column to the caller", () => {
100
+ expect(() =>
101
+ extract(`
102
+ export type Post = Model<{ id: UUID; user_id: UUID; title: Slug }, {
103
+ access: { read: Eq<"user_id", AuthUid> }
104
+ cache: { enabled: true; public: true }
105
+ }>`),
106
+ ).toThrow(/cache\.public/)
107
+ })
108
+
109
+ it("suggests the fix rather than only refusing", () => {
110
+ let message = ""
111
+ try {
112
+ extract(`
113
+ export type Post = Model<{ id: UUID; user_id: UUID; title: Slug }, {
114
+ access: { read: Owner<"user_id"> }
115
+ cache: { enabled: true; public: true }
116
+ }>`)
117
+ } catch (e) {
118
+ message = e instanceof Error ? e.message : String(e)
119
+ }
120
+ expect(message).toContain("enabled: true")
121
+ expect(message).toContain("published_at")
122
+ })
123
+ })
124
+
125
+ describe("public caching where sharing is the point", () => {
126
+ it("is allowed on a Public read rule", () => {
127
+ const ast = extract(`
128
+ export type Post = Model<{ id: UUID; title: Slug }, {
129
+ access: { read: Public }
130
+ cache: { enabled: true; public: true }
131
+ }>`)
132
+ expect(cacheOf(ast)).toEqual({ enabled: true, public: true })
133
+ })
134
+
135
+ it("is allowed on a rule that varies by row and by time but not by caller", () => {
136
+ // Refusing this would remove the case public caching exists to serve, so it is asserted from
137
+ // the permitting side too — a classifier stuck on "identity-dependent" would pass every test
138
+ // above and fail this one.
139
+ const ast = extract(`
140
+ export type Post = Model<{ id: UUID; published_at: Optional<DateTime>; title: Slug }, {
141
+ access: { read: Lte<"published_at", Now> }
142
+ cache: { enabled: true; public: true }
143
+ }>`)
144
+ expect(cacheOf(ast)).toEqual({ enabled: true, public: true })
145
+ })
146
+
147
+ it("is allowed when public is not asked for, whatever the read rule", () => {
148
+ const ast = extract(`
149
+ export type Post = Model<{ id: UUID; user_id: UUID; title: Slug }, {
150
+ access: { read: Owner<"user_id"> }
151
+ cache: { enabled: true }
152
+ }>`)
153
+ expect(cacheOf(ast)).toEqual({ enabled: true })
154
+ })
155
+ })
156
+
157
+ describe("the row cache needs something to key on", () => {
158
+ it("refuses rows on a model with no primary key", () => {
159
+ expect(() =>
160
+ extract(`
161
+ export type Reading = Model<{ sensor: Slug; value: Slug }, {
162
+ access: { read: Public }
163
+ cache: { enabled: true; rows: true }
164
+ }>`),
165
+ ).toThrow(/cache\.rows.*primary key|primary key.*cache\.rows/s)
166
+ })
167
+
168
+ it("allows rows where there is a primary key", () => {
169
+ const ast = extract(`
170
+ export type Post = Model<{ id: UUID; title: Slug }, {
171
+ access: { read: Public }
172
+ cache: { enabled: true; rows: true }
173
+ }>`)
174
+ expect(cacheOf(ast)).toEqual({ enabled: true, rows: true })
175
+ })
176
+
177
+ it("says what to do instead of only refusing", () => {
178
+ let message = ""
179
+ try {
180
+ extract(`
181
+ export type Reading = Model<{ sensor: Slug; value: Slug }, {
182
+ access: { read: Public }
183
+ cache: { rows: true }
184
+ }>`)
185
+ } catch (e) {
186
+ message = e instanceof Error ? e.message : String(e)
187
+ }
188
+ expect(message).toContain("response cache")
189
+ })
190
+
191
+ it("does not complain about a missing key when rows was not asked for", () => {
192
+ const ast = extract(`
193
+ export type Reading = Model<{ sensor: Slug; value: Slug }, {
194
+ access: { read: Public }
195
+ cache: { enabled: true }
196
+ }>`)
197
+ expect(cacheOf(ast)).toEqual({ enabled: true })
198
+ })
199
+ })
200
+
201
+ describe("the TTL bound, enforced where the number was written", () => {
202
+ const withTtl = (ttl: string) => () =>
203
+ extract(`
204
+ export type Post = Model<{
205
+ id: UUID
206
+ title: Slug
207
+ }, {
208
+ access: { read: Public }
209
+ cache: { enabled: true; maxTtl: ${ttl} }
210
+ }>`)
211
+
212
+ it("refuses a cap the admin API would refuse", () => {
213
+ // 86400 is the bound `PATCH /admin/v1/config/rest` enforces on the project-wide TTL, and a
214
+ // declared cap is the same quantity. Left to the server it arrives inside a manifest rather
215
+ // than a request, and nothing refuses a manifest.
216
+ expect(withTtl("90000")).toThrow(/between 0 and 86400 seconds/)
217
+ expect(withTtl("-1")).toThrow(/between 0 and 86400 seconds/)
218
+ })
219
+
220
+ it("refuses a fractional cap rather than rounding one", () => {
221
+ expect(withTtl("1.5")).toThrow(/whole number of seconds/)
222
+ })
223
+
224
+ it("accepts the bounds themselves", () => {
225
+ expect(withTtl("86400")).not.toThrow()
226
+ // Zero is the absence of a declared cap, not a cap of zero — both ends read it that way.
227
+ expect(withTtl("0")).not.toThrow()
228
+ })
229
+ })
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { manifestCache } from "../src/model-cache.js"
3
+
4
+ // The declaration reaches the server on the route manifest, keyed by table, exactly as `hooks`
5
+ // does — and for the same reason. The schema engine re-serialises its own parsed struct when it
6
+ // writes `ast_snapshot`, and its platform annotations know only `access` and `searchFields`, so a
7
+ // `cache` key left to ride the AST is dropped in silence somewhere between push and the server.
8
+
9
+ function ast(models: unknown[]): unknown {
10
+ return { models }
11
+ }
12
+
13
+ function model(table: string, cache?: unknown): unknown {
14
+ return {
15
+ name: table,
16
+ annotations: { db: { tableName: table }, platform: { ...(cache !== undefined && { cache }) } },
17
+ }
18
+ }
19
+
20
+ describe("what reaches the manifest", () => {
21
+ it("keys by table, not by model name", () => {
22
+ // The model name never reaches the wire; a REST path carries the table.
23
+ const out = manifestCache(
24
+ ast([
25
+ {
26
+ name: "BlogPost",
27
+ annotations: { db: { tableName: "blog_posts" }, platform: { cache: { enabled: true } } },
28
+ },
29
+ ]),
30
+ )
31
+ expect(out).toEqual({ blog_posts: { enabled: true } })
32
+ })
33
+
34
+ it("carries all four settings", () => {
35
+ const out = manifestCache(ast([model("posts", { enabled: true, maxTtl: 60, public: true, rows: true })]))
36
+ expect(out).toEqual({ posts: { enabled: true, maxTtl: 60, public: true, rows: true } })
37
+ })
38
+
39
+ it("omits a model that declared nothing, rather than writing an empty entry", () => {
40
+ // Load-bearing: the declaration is a ceiling, so absence means nothing may cache this table.
41
+ // An empty entry would say "declared, permitting nothing" — the same outcome by a route that
42
+ // looks like a bug.
43
+ expect(manifestCache(ast([model("posts")]))).toEqual({})
44
+ })
45
+
46
+ it("keeps an explicit false, which is a hard opt-out and not silence", () => {
47
+ expect(manifestCache(ast([model("posts", { enabled: false })]))).toEqual({
48
+ posts: { enabled: false },
49
+ })
50
+ })
51
+
52
+ it("carries maxTtl of zero, which means cache nothing rather than no opinion", () => {
53
+ expect(manifestCache(ast([model("posts", { maxTtl: 0 })]))).toEqual({ posts: { maxTtl: 0 } })
54
+ })
55
+ })
56
+
57
+ describe("what does not reach the manifest", () => {
58
+ it("drops keys the AST shape does not define", () => {
59
+ // Copied key by key on purpose. The manifest is a wire format another process parses, and
60
+ // widening it by accident is how the two ends stop agreeing what a field means.
61
+ const out = manifestCache(ast([model("posts", { enabled: true, somethingNew: true })]))
62
+ expect(out).toEqual({ posts: { enabled: true } })
63
+ })
64
+
65
+ it("drops a non-numeric or negative maxTtl rather than passing it on", () => {
66
+ expect(manifestCache(ast([model("a", { maxTtl: "60" })]))).toEqual({})
67
+ expect(manifestCache(ast([model("b", { maxTtl: -1 })]))).toEqual({})
68
+ expect(manifestCache(ast([model("c", { maxTtl: Number.NaN })]))).toEqual({})
69
+ expect(manifestCache(ast([model("d", { maxTtl: Number.POSITIVE_INFINITY })]))).toEqual({})
70
+ })
71
+
72
+ it("drops a non-boolean flag", () => {
73
+ expect(manifestCache(ast([model("posts", { enabled: "yes", rows: 1 })]))).toEqual({})
74
+ })
75
+
76
+ it("skips a model with no table name", () => {
77
+ expect(
78
+ manifestCache(ast([{ name: "X", annotations: { platform: { cache: { enabled: true } } } }])),
79
+ ).toEqual({})
80
+ })
81
+ })
82
+
83
+ describe("shapes that should not throw", () => {
84
+ it("survives an AST that is not one", () => {
85
+ expect(manifestCache(null)).toEqual({})
86
+ expect(manifestCache({})).toEqual({})
87
+ expect(manifestCache({ models: "not an array" })).toEqual({})
88
+ })
89
+
90
+ it("survives a model with no annotations at all", () => {
91
+ expect(manifestCache(ast([{ name: "X" }, null, 7]))).toEqual({})
92
+ })
93
+
94
+ it("survives a null cache", () => {
95
+ expect(
96
+ manifestCache(ast([{ annotations: { db: { tableName: "t" }, platform: { cache: null } } }])),
97
+ ).toEqual({})
98
+ })
99
+ })
@@ -5,7 +5,9 @@ import { afterEach, describe, expect, it } from "vitest"
5
5
  import {
6
6
  DEFAULT_HOOK_TIMEOUT_MS,
7
7
  declaredHooks,
8
+ declaredValidators,
8
9
  manifestHooks,
10
+ manifestValidators,
9
11
  syncManifestHooks,
10
12
  hooksReport,
11
13
  validateModelHooks,
@@ -376,3 +378,56 @@ export type Post = Model<{ id: UUID }, { tableName: "posts" }>
376
378
  expect(report).toMatchObject({ missing: [], functionsDisabled: false, mapMissing: false })
377
379
  })
378
380
  })
381
+
382
+ describe("a models array with something malformed in it", () => {
383
+ /*
384
+ * `x?.y` guards `x.y` being nullish, not `x` itself being null — so every one of these read
385
+ * `model.annotations` off a null and threw a TypeError out of `supatype push`. Not reachable
386
+ * from our own extractor, which never emits a null model; reachable from a hand-edited or
387
+ * third-party AST, and the failure is a stack trace rather than the entry being skipped.
388
+ *
389
+ * Asserted over all four readers at once, because the hole was copied between them: three
390
+ * already had it when the fourth was written, and only the fourth's own test found it.
391
+ */
392
+ const malformed = {
393
+ models: [
394
+ null,
395
+ undefined,
396
+ "posts",
397
+ 42,
398
+ [],
399
+ { annotations: null },
400
+ { annotations: { db: null, platform: null } },
401
+ // One real model at the end, so a reader that bailed out of the loop rather than skipping
402
+ // the entry would be caught too.
403
+ {
404
+ name: "Post",
405
+ annotations: {
406
+ db: { tableName: "posts" },
407
+ platform: {
408
+ hooks: { beforeChange: { function: "moderate-post" } },
409
+ validate: { title: { function: "check-title" } },
410
+ },
411
+ },
412
+ },
413
+ ],
414
+ }
415
+
416
+ it("skips the bad entries rather than throwing", () => {
417
+ expect(() => declaredHooks(malformed)).not.toThrow()
418
+ expect(() => manifestHooks(malformed)).not.toThrow()
419
+ expect(() => declaredValidators(malformed)).not.toThrow()
420
+ expect(() => manifestValidators(malformed)).not.toThrow()
421
+ })
422
+
423
+ it("still reads the models that are well formed", () => {
424
+ expect(declaredHooks(malformed)).toEqual([
425
+ { model: "Post", event: "beforeChange", function: "moderate-post" },
426
+ ])
427
+ expect(manifestHooks(malformed)["posts"]?.["beforeChange"]?.function).toBe("moderate-post")
428
+ expect(declaredValidators(malformed)).toEqual([
429
+ { model: "Post", field: "title", function: "check-title" },
430
+ ])
431
+ expect(manifestValidators(malformed)["posts"]?.["title"]?.function).toBe("check-title")
432
+ })
433
+ })
@@ -0,0 +1,229 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { afterEach, describe, expect, it } from "vitest"
5
+ import { extractSchemaAstFromTypes } from "../src/type-extractor.js"
6
+ import {
7
+ DEFAULT_VERSIONS_KEPT,
8
+ modelsWithVersionsAndFieldRules,
9
+ resolveVersionsOptions,
10
+ schemaHasVersionedModels,
11
+ versionedModels,
12
+ } from "../src/model-versioning.js"
13
+ import { apiSchemaList, draftVisibilityRoles, previewLimits } from "../src/project-config.js"
14
+ import type { SupatypeProjectConfig } from "../src/project-config.js"
15
+
16
+ // A versioned model is opt-in and changes three things that are decided before any database exists:
17
+ // the exposed schema list gains `draft`, the engine emits a versions table and a draft view, and a
18
+ // model that also masks a column is refused. All three read `versions` out of the extracted AST, so
19
+ // what the extractor puts there is the contract these tests pin.
20
+
21
+ const dirs: string[] = []
22
+
23
+ afterEach(() => {
24
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
25
+ })
26
+
27
+ function extract(body: string): ReturnType<typeof extractSchemaAstFromTypes> {
28
+ const dir = mkdtempSync(join(tmpdir(), "supatype-versions-"))
29
+ dirs.push(dir)
30
+ const schemaPath = join(dir, "schema.ts")
31
+ writeFileSync(
32
+ schemaPath,
33
+ `
34
+ import type {
35
+ Lte, Model, Now, Optional, Owner, Public, RichText, Role, Timestamp, UUID,
36
+ } from "@supatype/types"
37
+
38
+ ${body}
39
+ `,
40
+ "utf8",
41
+ )
42
+ return extractSchemaAstFromTypes(schemaPath, dir)
43
+ }
44
+
45
+ const POST = `
46
+ export type Post = Model<{
47
+ id: UUID
48
+ title: string
49
+ body: RichText
50
+ published_at: Optional<Timestamp>
51
+ }, {
52
+ tableName: "posts"
53
+ versions: { drafts: true; keep: 5 }
54
+ access: { read: Lte<"published_at", Now>; update: Role<"editor"> }
55
+ }>
56
+ `
57
+
58
+ describe("the versions declaration", () => {
59
+ it("reaches the AST with its retention", () => {
60
+ const ast = extract(POST)
61
+ expect(ast?.models[0]?.options["versions"]).toEqual({ drafts: true, keep: 5 })
62
+ })
63
+
64
+ it("takes `true` as shorthand for drafts with the default retention", () => {
65
+ const ast = extract(`
66
+ export type Note = Model<{ id: UUID; title: string }, { versions: true }>
67
+ `)
68
+ expect(ast?.models[0]?.options["versions"]).toEqual({
69
+ drafts: true,
70
+ keep: DEFAULT_VERSIONS_KEPT,
71
+ })
72
+ })
73
+
74
+ it("keeps history without withholding writes when drafts are off", () => {
75
+ // The audit-trail case: every change recorded, nothing hidden from readers.
76
+ const ast = extract(`
77
+ export type Ledger = Model<{ id: UUID; note: string }, { versions: { drafts: false } }>
78
+ `)
79
+ expect(ast?.models[0]?.options["versions"]).toEqual({
80
+ drafts: false,
81
+ keep: DEFAULT_VERSIONS_KEPT,
82
+ })
83
+ })
84
+
85
+ it("is absent from a model that does not declare it", () => {
86
+ const ast = extract(`export type Plain = Model<{ id: UUID; title: string }>`)
87
+ expect(ast?.models[0]?.options["versions"]).toBeUndefined()
88
+ expect(schemaHasVersionedModels(ast)).toBe(false)
89
+ })
90
+
91
+ it("names the model and its table, which is what the generated relations are named after", () => {
92
+ const found = versionedModels(extract(POST))
93
+ expect(found).toEqual([
94
+ { name: "Post", tableName: "posts", options: { drafts: true, keep: 5 } },
95
+ ])
96
+ })
97
+ })
98
+
99
+ describe("versions alongside per-column rules", () => {
100
+ it("refuses the pair, naming the masked column", () => {
101
+ // A snapshot is opaque JSONB, so the masking that rewrites references to a real column cannot
102
+ // see inside it: the value would be readable in the versions table.
103
+ expect(() =>
104
+ extract(`
105
+ export type Employee = Model<{ id: UUID; name: string; salary: number }, {
106
+ versions: true
107
+ access: { read: Public; fields: { salary: { read: Owner<"id"> } } }
108
+ }>
109
+ `),
110
+ ).toThrow(/`versions` and `access.fields` cannot both be declared[\s\S]*`salary`/)
111
+ })
112
+
113
+ it("allows a masked model and a versioned model in the same schema", () => {
114
+ // Per model, not per project. Under the view tier the managed schema comes off the exposed list
115
+ // but the API roles keep their privileges on the base tables, so a `security_invoker` view in an
116
+ // exposed schema reaches past the `api` layer: one model may not be both, a project may hold
117
+ // both.
118
+ const ast = extract(`
119
+ export type Employee = Model<{ id: UUID; name: string; salary: number }, {
120
+ access: { read: Public; fields: { salary: { read: Owner<"id"> } } }
121
+ }>
122
+
123
+ export type Article = Model<{ id: UUID; title: string }, { versions: true }>
124
+ `)
125
+ expect(ast?.models).toHaveLength(2)
126
+ expect(schemaHasVersionedModels(ast)).toBe(true)
127
+ expect(modelsWithVersionsAndFieldRules(ast)).toEqual([])
128
+ })
129
+ })
130
+
131
+ describe("resolveVersionsOptions", () => {
132
+ it("treats a retention of zero as unstated, since pruning to nothing deletes the open draft", () => {
133
+ expect(resolveVersionsOptions({ keep: 0 })).toEqual({
134
+ drafts: true,
135
+ keep: DEFAULT_VERSIONS_KEPT,
136
+ })
137
+ })
138
+
139
+ it("reads `false` and a non-object as no versioning at all", () => {
140
+ expect(resolveVersionsOptions(false)).toBeUndefined()
141
+ expect(resolveVersionsOptions(undefined)).toBeUndefined()
142
+ expect(resolveVersionsOptions("true")).toBeUndefined()
143
+ })
144
+ })
145
+
146
+ function project(overrides?: Partial<SupatypeProjectConfig>): SupatypeProjectConfig {
147
+ return {
148
+ projectName: "test",
149
+ database: { provider: "docker" },
150
+ ...overrides,
151
+ } as SupatypeProjectConfig
152
+ }
153
+
154
+ describe("the exposed schema list with drafts", () => {
155
+ it("adds `draft` behind the managed schema, never in front of it", () => {
156
+ // The first entry is PostgREST's default profile. `draft` leading would mean a client that named
157
+ // no profile read drafts by default, which inverts the whole feature.
158
+ expect(apiSchemaList(project(), { drafts: true })).toBe(
159
+ "public, draft, supatype, graphql_public, auth",
160
+ )
161
+ })
162
+
163
+ it("is unchanged when nothing is versioned", () => {
164
+ expect(apiSchemaList(project(), { drafts: false })).toBe(
165
+ "public, supatype, graphql_public, auth",
166
+ )
167
+ })
168
+
169
+ it("still lets an explicit api_schemas win, drafts or not", () => {
170
+ // Stating the list is the documented way to stop exposing a schema, and that has to hold for
171
+ // `draft` as much as for `supatype`.
172
+ const cfg = project({ schema: { api_schemas: ["public"] } })
173
+ expect(apiSchemaList(cfg, { drafts: true })).toBe("public")
174
+ })
175
+
176
+ it("serves drafts from the managed schema even under the view tier", () => {
177
+ // The draft views read the real tables, so tier 2 moving reads to `api` does not move them.
178
+ expect(apiSchemaList(project(), { tier: "views", drafts: true })).toBe(
179
+ "api, draft, supatype, graphql_public, auth",
180
+ )
181
+ })
182
+ })
183
+
184
+ describe("draft visibility", () => {
185
+ it("defaults to every Studio role, so seeing and acting line up", () => {
186
+ // Narrower than this put two decisions in conflict: whoever may update a record may draft it,
187
+ // so a role that can edit but cannot see drafts saves over a colleague's pending work.
188
+ expect(draftVisibilityRoles(project())).toEqual(["admin", "developer", "editor"])
189
+ })
190
+
191
+ it("honours an empty list as creators only", () => {
192
+ // Not a fallback case: narrowing to creators alone is a legitimate setting, so emptiness cannot
193
+ // mean "unstated".
194
+ expect(draftVisibilityRoles(project({ publishing: { draft_visibility: [] } }))).toEqual([])
195
+ })
196
+
197
+ it("drops a role no deployment understands", () => {
198
+ // A typo passed through to a policy would be a role nothing can hold, which reads as a working
199
+ // setting that grants nobody.
200
+ const cfg = project({
201
+ publishing: { draft_visibility: ["editor", "owner", "editor"] as never },
202
+ })
203
+ expect(draftVisibilityRoles(cfg)).toEqual(["editor"])
204
+ })
205
+ })
206
+
207
+ describe("preview limits", () => {
208
+ it("defaults to a short link, a week for a record and a day for a project", () => {
209
+ expect(previewLimits(project())).toEqual({
210
+ defaultTtl: 900,
211
+ maxRecordTtl: 604800,
212
+ maxProjectTtl: 86400,
213
+ allowProjectScope: true,
214
+ })
215
+ })
216
+
217
+ it("clamps a default lifetime above every ceiling it could be issued against", () => {
218
+ // Otherwise the same config mints links it then refuses to honour.
219
+ const cfg = project({
220
+ publishing: { preview: { default_ttl: 999999, max_record_ttl: 3600, max_project_ttl: 600 } },
221
+ })
222
+ expect(previewLimits(cfg).defaultTtl).toBe(3600)
223
+ })
224
+
225
+ it("ignores a nonsense lifetime rather than issuing a link that has already expired", () => {
226
+ const cfg = project({ publishing: { preview: { default_ttl: 0 } } })
227
+ expect(previewLimits(cfg).defaultTtl).toBe(900)
228
+ })
229
+ })