@supatype/cli 0.2.1 → 0.3.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 (173) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +193 -157
  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/admin.d.ts.map +1 -1
  20. package/dist/commands/admin.js +21 -4
  21. package/dist/commands/admin.js.map +1 -1
  22. package/dist/commands/cache.d.ts +1 -1
  23. package/dist/commands/cache.js +3 -3
  24. package/dist/commands/cache.js.map +1 -1
  25. package/dist/commands/cloud.d.ts.map +1 -1
  26. package/dist/commands/cloud.js +2 -1
  27. package/dist/commands/cloud.js.map +1 -1
  28. package/dist/commands/deploy.d.ts.map +1 -1
  29. package/dist/commands/deploy.js +2 -1
  30. package/dist/commands/deploy.js.map +1 -1
  31. package/dist/commands/dev.d.ts.map +1 -1
  32. package/dist/commands/dev.js +61 -8
  33. package/dist/commands/dev.js.map +1 -1
  34. package/dist/commands/diff.d.ts.map +1 -1
  35. package/dist/commands/diff.js +2 -1
  36. package/dist/commands/diff.js.map +1 -1
  37. package/dist/commands/functions.d.ts.map +1 -1
  38. package/dist/commands/functions.js +6 -2
  39. package/dist/commands/functions.js.map +1 -1
  40. package/dist/commands/init.d.ts.map +1 -1
  41. package/dist/commands/init.js +13 -9
  42. package/dist/commands/init.js.map +1 -1
  43. package/dist/commands/keys.d.ts +35 -0
  44. package/dist/commands/keys.d.ts.map +1 -1
  45. package/dist/commands/keys.js +90 -6
  46. package/dist/commands/keys.js.map +1 -1
  47. package/dist/commands/migrate.d.ts.map +1 -1
  48. package/dist/commands/migrate.js +2 -1
  49. package/dist/commands/migrate.js.map +1 -1
  50. package/dist/commands/push.d.ts.map +1 -1
  51. package/dist/commands/push.js +61 -1
  52. package/dist/commands/push.js.map +1 -1
  53. package/dist/compose-services.d.ts.map +1 -1
  54. package/dist/compose-services.js +2 -1
  55. package/dist/compose-services.js.map +1 -1
  56. package/dist/dev-compose.d.ts +13 -0
  57. package/dist/dev-compose.d.ts.map +1 -1
  58. package/dist/dev-compose.js +119 -16
  59. package/dist/dev-compose.js.map +1 -1
  60. package/dist/engine-floor.d.ts +11 -1
  61. package/dist/engine-floor.d.ts.map +1 -1
  62. package/dist/engine-floor.js +49 -16
  63. package/dist/engine-floor.js.map +1 -1
  64. package/dist/functions-deno-types.d.ts +9 -0
  65. package/dist/functions-deno-types.d.ts.map +1 -1
  66. package/dist/functions-deno-types.js +45 -1
  67. package/dist/functions-deno-types.js.map +1 -1
  68. package/dist/functions-router-gen.d.ts.map +1 -1
  69. package/dist/functions-router-gen.js +113 -81
  70. package/dist/functions-router-gen.js.map +1 -1
  71. package/dist/kong-config.d.ts +1 -1
  72. package/dist/model-cache.d.ts +44 -0
  73. package/dist/model-cache.d.ts.map +1 -0
  74. package/dist/model-cache.js +106 -0
  75. package/dist/model-cache.js.map +1 -0
  76. package/dist/model-hooks.d.ts +5 -0
  77. package/dist/model-hooks.d.ts.map +1 -1
  78. package/dist/model-hooks.js +29 -1
  79. package/dist/model-hooks.js.map +1 -1
  80. package/dist/model-versioning.d.ts +80 -0
  81. package/dist/model-versioning.d.ts.map +1 -0
  82. package/dist/model-versioning.js +140 -0
  83. package/dist/model-versioning.js.map +1 -0
  84. package/dist/postgres-ctl.d.ts +55 -0
  85. package/dist/postgres-ctl.d.ts.map +1 -1
  86. package/dist/postgres-ctl.js +84 -8
  87. package/dist/postgres-ctl.js.map +1 -1
  88. package/dist/preview-config-check.d.ts +21 -0
  89. package/dist/preview-config-check.d.ts.map +1 -0
  90. package/dist/preview-config-check.js +47 -0
  91. package/dist/preview-config-check.js.map +1 -0
  92. package/dist/project-config.d.ts +182 -5
  93. package/dist/project-config.d.ts.map +1 -1
  94. package/dist/project-config.js +125 -8
  95. package/dist/project-config.js.map +1 -1
  96. package/dist/resolve-target.d.ts +9 -0
  97. package/dist/resolve-target.d.ts.map +1 -1
  98. package/dist/resolve-target.js.map +1 -1
  99. package/dist/rest-cache-admin.d.ts.map +1 -1
  100. package/dist/rest-cache-admin.js +6 -2
  101. package/dist/rest-cache-admin.js.map +1 -1
  102. package/dist/schema-ast-v2.d.ts +32 -1
  103. package/dist/schema-ast-v2.d.ts.map +1 -1
  104. package/dist/schema-ast-v2.js +14 -1
  105. package/dist/schema-ast-v2.js.map +1 -1
  106. package/dist/self-host-compose.d.ts +20 -1
  107. package/dist/self-host-compose.d.ts.map +1 -1
  108. package/dist/self-host-compose.js +227 -29
  109. package/dist/self-host-compose.js.map +1 -1
  110. package/dist/studio-admin-roles.d.ts +8 -1
  111. package/dist/studio-admin-roles.d.ts.map +1 -1
  112. package/dist/studio-admin-roles.js +16 -2
  113. package/dist/studio-admin-roles.js.map +1 -1
  114. package/dist/type-extractor.d.ts +7 -0
  115. package/dist/type-extractor.d.ts.map +1 -1
  116. package/dist/type-extractor.js +234 -3
  117. package/dist/type-extractor.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/api-config-cache.ts +142 -0
  120. package/src/cache-identity-scope.ts +92 -0
  121. package/src/cache-provider.ts +61 -0
  122. package/src/cli-version-embedded.ts +1 -1
  123. package/src/commands/add.ts +1 -1
  124. package/src/commands/admin.ts +23 -6
  125. package/src/commands/cache.ts +3 -3
  126. package/src/commands/cloud.ts +2 -1
  127. package/src/commands/deploy.ts +2 -1
  128. package/src/commands/dev.ts +68 -7
  129. package/src/commands/diff.ts +2 -1
  130. package/src/commands/functions.ts +6 -2
  131. package/src/commands/init.ts +13 -9
  132. package/src/commands/keys.ts +112 -7
  133. package/src/commands/migrate.ts +2 -1
  134. package/src/commands/push.ts +68 -1
  135. package/src/compose-services.ts +2 -1
  136. package/src/dev-compose.ts +151 -15
  137. package/src/engine-floor.ts +73 -16
  138. package/src/functions-deno-types.ts +48 -1
  139. package/src/functions-router-gen.ts +113 -81
  140. package/src/kong-config.ts +1 -1
  141. package/src/model-cache.ts +122 -0
  142. package/src/model-hooks.ts +26 -2
  143. package/src/model-versioning.ts +167 -0
  144. package/src/postgres-ctl.ts +103 -9
  145. package/src/preview-config-check.ts +52 -0
  146. package/src/project-config.ts +273 -11
  147. package/src/resolve-target.ts +11 -1
  148. package/src/rest-cache-admin.ts +8 -2
  149. package/src/schema-ast-v2.ts +47 -0
  150. package/src/self-host-compose.ts +250 -29
  151. package/src/studio-admin-roles.ts +16 -2
  152. package/src/type-extractor.ts +281 -2
  153. package/tests/api-config-cache-seed.test.ts +180 -0
  154. package/tests/cache-identity-scope.test.ts +61 -0
  155. package/tests/cache-provider-config.test.ts +132 -0
  156. package/tests/engine-floor.test.ts +43 -0
  157. package/tests/external-database-compose.test.ts +74 -3
  158. package/tests/field-masking-tier.test.ts +3 -3
  159. package/tests/fixtures/identity-scope-corpus.json +42 -0
  160. package/tests/functions-concurrency.test.ts +71 -0
  161. package/tests/init.test.ts +36 -2
  162. package/tests/keys-write.test.ts +148 -0
  163. package/tests/model-cache-declaration.test.ts +229 -0
  164. package/tests/model-cache-manifest.test.ts +99 -0
  165. package/tests/model-hooks.test.ts +55 -0
  166. package/tests/model-versions.test.ts +229 -0
  167. package/tests/native-keyspace.test.ts +130 -0
  168. package/tests/preview-config-check.test.ts +104 -0
  169. package/tests/rest-cache-admin.test.ts +36 -0
  170. package/tests/runtime-contract.test.ts +176 -22
  171. package/tests/studio-admin-roles.test.ts +32 -0
  172. package/tests/type-extractor.test.ts +113 -0
  173. package/tsconfig.tsbuildinfo +1 -1
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs"
2
2
  import { createHash } from "node:crypto"
3
3
  import { dirname, isAbsolute, relative, resolve } from "node:path"
4
4
  import ts from "typescript"
5
+ import { isIdentityDependent, type AccessRuleNode } from "./cache-identity-scope.js"
5
6
  import {
6
7
  applyImportRename,
7
8
  createResolveContext,
@@ -107,7 +108,17 @@ export function extractSchemaAstFromTypes(
107
108
  )
108
109
  }
109
110
 
110
- const { tableName, access, options, indexes, constraints, hooks, validators } =
111
+ const {
112
+ tableName,
113
+ access,
114
+ options,
115
+ indexes,
116
+ constraints,
117
+ hooks,
118
+ validators,
119
+ searchFields,
120
+ cache,
121
+ } =
111
122
  parseModelMeta(
112
123
  metaArg,
113
124
  sourceFile,
@@ -128,6 +139,8 @@ export function extractSchemaAstFromTypes(
128
139
  hooks,
129
140
  constraints,
130
141
  validators,
142
+ searchFields,
143
+ cache,
131
144
  ),
132
145
  )
133
146
  }
@@ -474,6 +487,7 @@ function parseFieldType(
474
487
  fieldDefault: undefined as string | number | boolean | null | undefined,
475
488
  localized: false,
476
489
  notLocalized: false,
490
+ searchable: false,
477
491
  bounds: {} as DeclaredBounds,
478
492
  }
479
493
 
@@ -522,6 +536,10 @@ function parseFieldType(
522
536
  continue
523
537
  }
524
538
  case "Searchable":
539
+ // Used to unwrap and drop the declaration on the floor, so a schema saying a column was
540
+ // searchable compiled, pushed, and produced a Studio list view with no search box: the
541
+ // engine was never told, so the admin config carried no `searchFields` for Studio to read.
542
+ flags.searchable = true
525
543
  current = current.typeArguments?.[0] ?? current
526
544
  continue
527
545
  case "EditorReadOnly":
@@ -674,6 +692,7 @@ function parseFieldType(
674
692
  platform: {
675
693
  ...scalarBase.platform,
676
694
  ...(flags.editorReadOnly && { readOnly: true }),
695
+ ...(flags.searchable && { searchable: true }),
677
696
  },
678
697
  }
679
698
 
@@ -1604,6 +1623,47 @@ function parseAssetFieldOptions(
1604
1623
  return { localized: false }
1605
1624
  }
1606
1625
 
1626
+ /** What `versions` resolved to on a model, or absent when the model declares none. */
1627
+ type ParsedVersions = { drafts: boolean; keep: number }
1628
+
1629
+ /** Versions kept per record when a model states no retention. Mirrors `model-versioning.ts`. */
1630
+ const DEFAULT_VERSIONS_KEPT = 20
1631
+
1632
+ /**
1633
+ * `versions: true` or `versions: { drafts, keep }`.
1634
+ *
1635
+ * A malformed member is ignored rather than fatal, matching every other key here: the declaration is
1636
+ * a *type*, so TypeScript has already refused anything `ModelVersionsOptions` does not allow, and
1637
+ * this parser's job is reading what compiled rather than validating it a second time.
1638
+ */
1639
+ function parseVersions(typeNode: ts.TypeNode): ParsedVersions | undefined {
1640
+ if (isBooleanLiteralType(typeNode, true)) {
1641
+ return { drafts: true, keep: DEFAULT_VERSIONS_KEPT }
1642
+ }
1643
+ if (isBooleanLiteralType(typeNode, false)) return undefined
1644
+ if (!ts.isTypeLiteralNode(typeNode)) return undefined
1645
+
1646
+ const parsed: ParsedVersions = { drafts: true, keep: DEFAULT_VERSIONS_KEPT }
1647
+ for (const member of typeNode.members) {
1648
+ if (!ts.isPropertySignature(member) || !member.type) continue
1649
+ const key = getPropertyName(member.name)
1650
+ if (key === "drafts") {
1651
+ if (isBooleanLiteralType(member.type, false)) parsed.drafts = false
1652
+ if (isBooleanLiteralType(member.type, true)) parsed.drafts = true
1653
+ } else if (
1654
+ key === "keep" &&
1655
+ ts.isLiteralTypeNode(member.type) &&
1656
+ ts.isNumericLiteral(member.type.literal)
1657
+ ) {
1658
+ const keep = Number(member.type.literal.text)
1659
+ // A retention of zero would prune the draft being edited, so it reads as "unstated" rather
1660
+ // than as an instruction to keep nothing.
1661
+ if (Number.isFinite(keep) && keep >= 1) parsed.keep = Math.floor(keep)
1662
+ }
1663
+ }
1664
+ return parsed
1665
+ }
1666
+
1607
1667
  function parseMetaLiteral(
1608
1668
  metaArg: ts.TypeNode | undefined,
1609
1669
  sourceFile: ts.SourceFile,
@@ -1613,6 +1673,8 @@ function parseMetaLiteral(
1613
1673
  timestamps?: boolean
1614
1674
  softDelete?: boolean
1615
1675
  autoLocalize?: boolean
1676
+ versions?: ParsedVersions
1677
+ searchable?: string[]
1616
1678
  } {
1617
1679
  const result: {
1618
1680
  tableName?: string
@@ -1620,6 +1682,8 @@ function parseMetaLiteral(
1620
1682
  timestamps?: boolean
1621
1683
  softDelete?: boolean
1622
1684
  autoLocalize?: boolean
1685
+ versions?: ParsedVersions
1686
+ searchable?: string[]
1623
1687
  } = {}
1624
1688
 
1625
1689
  if (!metaArg || !ts.isTypeLiteralNode(metaArg)) return result
@@ -1639,6 +1703,13 @@ function parseMetaLiteral(
1639
1703
  if (isBooleanLiteralType(member.type, false)) result.softDelete = false
1640
1704
  } else if (key === "autoLocalize" && isBooleanLiteralType(member.type, true)) {
1641
1705
  result.autoLocalize = true
1706
+ } else if (key === "searchable" && ts.isTupleTypeNode(member.type)) {
1707
+ result.searchable = member.type.elements
1708
+ .map((el) => (ts.isLiteralTypeNode(el) && ts.isStringLiteral(el.literal) ? el.literal.text : null))
1709
+ .filter((name): name is string => name !== null)
1710
+ } else if (key === "versions") {
1711
+ const versions = parseVersions(member.type)
1712
+ if (versions !== undefined) result.versions = versions
1642
1713
  } else if (
1643
1714
  key === "tableName" &&
1644
1715
  ts.isLiteralTypeNode(member.type) &&
@@ -1680,6 +1751,8 @@ function parseModelMeta(
1680
1751
  constraints: unknown[]
1681
1752
  hooks: Record<string, ParsedModelHook>
1682
1753
  validators: Record<string, ParsedModelHook>
1754
+ searchFields: string[]
1755
+ cache: ParsedModelCache | undefined
1683
1756
  } {
1684
1757
  const literal = parseMetaLiteral(metaArg, sourceFile)
1685
1758
  const singleton = literal.singleton === true
@@ -1700,16 +1773,162 @@ function parseModelMeta(
1700
1773
  if (timestamps) options.timestamps = true
1701
1774
  if (softDelete) options.softDelete = true
1702
1775
  if (literal.autoLocalize === true) options.autoLocalize = true
1776
+ if (literal.versions !== undefined) options.versions = literal.versions
1777
+
1778
+ const access = parseModelAccess(metaArg, sourceFile, modelName, fields, resolveCtx)
1779
+ if (literal.versions !== undefined) assertVersionsWithoutFieldRules(access, modelName)
1780
+
1781
+ const cache = parseModelCache(metaArg, sourceFile)
1782
+ if (cache !== undefined) assertCacheIsServable(cache, access, fields, modelName)
1703
1783
 
1704
1784
  return {
1705
1785
  tableName,
1706
- access: parseModelAccess(metaArg, sourceFile, modelName, fields, resolveCtx),
1786
+ access,
1707
1787
  options,
1788
+ searchFields: resolveSearchFields(literal.searchable, fields, modelName),
1708
1789
  indexes: parseModelIndexes(metaArg, sourceFile, fields),
1709
1790
  constraints: parseModelConstraints(metaArg, sourceFile, modelName, fields, resolveCtx),
1710
1791
  hooks: parseModelHooks(metaArg, sourceFile),
1711
1792
  validators: parseModelValidators(metaArg, sourceFile, modelName, fields),
1793
+ // Always present, possibly undefined: a conditional spread here infers the key away entirely
1794
+ // and the caller cannot destructure it. emitModel is what decides whether it reaches the AST.
1795
+ cache,
1796
+ }
1797
+ }
1798
+
1799
+ /**
1800
+ * Refuse a cache declaration the runtime cannot honour, naming the model and the reason.
1801
+ *
1802
+ * Both checks exist because the failure they prevent is silent. A public entry on a caller-varying
1803
+ * table serves one user's rows to another and looks like a cache hit. `rows: true` on a table with
1804
+ * no primary key is simply never registered, and reads stay exactly as fast as they were with a
1805
+ * setting in the schema saying otherwise.
1806
+ *
1807
+ * Checked here rather than left to the server for the same reason the constraint operands are: the
1808
+ * message can name what the author wrote.
1809
+ */
1810
+ function assertCacheIsServable(
1811
+ cache: ParsedModelCache,
1812
+ access: Record<string, unknown>,
1813
+ fields: Record<string, FieldAstV2>,
1814
+ model: string,
1815
+ ): void {
1816
+ if (cache.public === true) {
1817
+ const read = access["read"] as AccessRuleNode | undefined
1818
+ if (isIdentityDependent(read)) {
1819
+ throw new Error(
1820
+ `Model "${model}": \`cache.public\` cannot be used with an \`access.read\` rule that ` +
1821
+ `varies by caller. A public cache entry is shared by everyone, so one caller's rows ` +
1822
+ `would be served to another. Use \`cache: { enabled: true }\` for per-user entries, or ` +
1823
+ `make the read rule row-independent — a rule like \`Lte<"published_at", Now>\` varies by ` +
1824
+ `row without varying by caller and is safe to share.`,
1825
+ )
1826
+ }
1827
+ }
1828
+
1829
+ if (cache.rows === true) {
1830
+ const keyed = Object.keys(fields).filter((f) => fields[f]?.["primaryKey"] === true)
1831
+ if (keyed.length === 0) {
1832
+ throw new Error(
1833
+ `Model "${model}": \`cache.rows\` needs a primary key, because the row cache's key IS the ` +
1834
+ `primary key — there is nothing to cache by. Declare one, or drop \`rows\` and keep the ` +
1835
+ `response cache, which has no such requirement.`,
1836
+ )
1837
+ }
1838
+ }
1839
+
1840
+ assertTtlIsInRange(cache.maxTtl, model)
1841
+ }
1842
+
1843
+ /**
1844
+ * The columns Studio's list view searches, from either way of saying so.
1845
+ *
1846
+ * Two spellings, because they answer different questions: `Searchable<string>` on the field says
1847
+ * *this column is worth searching*, and `searchable: ["title", "name"]` on the model says *search
1848
+ * these, in this order*, which matters because the list view filters on the first one. A model
1849
+ * using both gets the explicit order first and any remaining flagged columns after it.
1850
+ *
1851
+ * A name that matches no field throws rather than being dropped. Silently ignoring it is how this
1852
+ * whole feature came to be inert: the declaration compiled and nothing downstream ever saw it.
1853
+ */
1854
+ function resolveSearchFields(
1855
+ declared: string[] | undefined,
1856
+ fields: Record<string, FieldAstV2>,
1857
+ modelName: string,
1858
+ ): string[] {
1859
+ const ordered: string[] = []
1860
+
1861
+ for (const name of declared ?? []) {
1862
+ if (fields[name] === undefined) {
1863
+ throw new Error(
1864
+ `Model "${modelName}": \`searchable\` names "${name}", which is not a field on this model. ` +
1865
+ `Searchable columns are: ${Object.keys(fields).join(", ")}.`,
1866
+ )
1867
+ }
1868
+ if (!ordered.includes(name)) ordered.push(name)
1869
+ }
1870
+
1871
+ for (const [name, field] of Object.entries(fields)) {
1872
+ if (field.annotations?.platform?.searchable !== true) continue
1873
+ if (!ordered.includes(name)) ordered.push(name)
1712
1874
  }
1875
+
1876
+ return ordered
1877
+ }
1878
+
1879
+ /**
1880
+ * The bounds the admin API already enforces, enforced where the number was written.
1881
+ *
1882
+ * `cache_max_ttl` is refused outside 0–86400 by `PATCH /admin/v1/config/rest`, and a declared cap
1883
+ * is the same quantity. Left to the server, an out-of-range `maxTtl` reaches it as part of a
1884
+ * manifest rather than a request — nothing refuses a manifest — and the ceiling it produces is
1885
+ * whatever the arithmetic makes of it: a negative cap reads as "no cap declared" and permits more
1886
+ * than the author asked for, which is the one direction a ceiling must never move in.
1887
+ */
1888
+ function assertTtlIsInRange(ttl: number | undefined, model: string): void {
1889
+ if (ttl === undefined) return
1890
+ if (!Number.isInteger(ttl)) {
1891
+ throw new Error(
1892
+ `Model "${model}": \`cache.maxTtl\` must be a whole number of seconds, not ${ttl}.`,
1893
+ )
1894
+ }
1895
+ if (ttl < 0 || ttl > MAX_CACHE_TTL_SECONDS) {
1896
+ throw new Error(
1897
+ `Model "${model}": \`cache.maxTtl\` must be between 0 and ${MAX_CACHE_TTL_SECONDS} seconds ` +
1898
+ `(24 hours), not ${ttl}. That is the same bound the admin API enforces on the project-wide ` +
1899
+ `cache TTL, and a declaration outside it is not a cap the runtime can apply.`,
1900
+ )
1901
+ }
1902
+ }
1903
+
1904
+ /** The admin API's bound on a cache TTL, in seconds. */
1905
+ const MAX_CACHE_TTL_SECONDS = 86_400
1906
+
1907
+ /**
1908
+ * Refuse a model that declares both `versions` and per-column rules.
1909
+ *
1910
+ * A version snapshot is opaque `jsonb`. `supatype_mask` is driven by security labels on a specific
1911
+ * table's column and rewrites references to *that* column; the view tier puts the same expression in
1912
+ * a view over the real columns. Neither can see inside a snapshot, so a masked value would sit in
1913
+ * plain sight in `<table>_versions` for anyone able to read it, and there is no narrower grant to
1914
+ * hide it behind now that drafts are visible to the record's creator.
1915
+ *
1916
+ * Refused here rather than left to the engine for the same reason the constraint operands are: the
1917
+ * message can name the model and the columns the author wrote.
1918
+ */
1919
+ function assertVersionsWithoutFieldRules(access: Record<string, unknown>, model: string): void {
1920
+ const fields = access["fields"]
1921
+ if (typeof fields !== "object" || fields === null) return
1922
+ const masked = Object.keys(fields)
1923
+ if (masked.length === 0) return
1924
+
1925
+ throw new Error(
1926
+ `Model "${model}": \`versions\` and \`access.fields\` cannot both be declared. ` +
1927
+ `A version snapshot is opaque JSONB, so the per-column rules on ` +
1928
+ `${masked.map((c) => `\`${c}\``).join(", ")} cannot be enforced inside it and the value would ` +
1929
+ `be readable in the versions table. Drop one: keep the field rules, or version a model that ` +
1930
+ `does not mask a column.`,
1931
+ )
1713
1932
  }
1714
1933
 
1715
1934
  /**
@@ -2980,3 +3199,63 @@ function resolveIndexedAccessRule(
2980
3199
  }
2981
3200
  return undefined
2982
3201
  }
3202
+
3203
+ // ─── cache declaration (P8, plan §13) ────────────────────────────────────────
3204
+
3205
+ /** The parsed `cache` block. Absent entirely when the model declared none. */
3206
+ export interface ParsedModelCache {
3207
+ enabled?: boolean
3208
+ maxTtl?: number
3209
+ public?: boolean
3210
+ rows?: boolean
3211
+ }
3212
+
3213
+ /**
3214
+ * Parse `cache: { ... }` off a model's meta argument.
3215
+ *
3216
+ * Shaped like `parseModelHooks`, and travelling the same way: onto the route manifest beside
3217
+ * `hooks`, not into `annotations.platform`. The schema engine re-serialises its own parsed AST
3218
+ * (`serde_json::to_value(&ast)`), and `PlatformModelAnnotations` carries only `access` and
3219
+ * `search_fields` with no catch-all — so a key placed there is silently dropped on the way to the
3220
+ * server rather than riding through. Verified rather than assumed; see plan §13.1.
3221
+ */
3222
+ function parseModelCache(
3223
+ metaArg: ts.TypeNode | undefined,
3224
+ sourceFile: ts.SourceFile,
3225
+ ): ParsedModelCache | undefined {
3226
+ if (!metaArg || !ts.isTypeLiteralNode(metaArg)) return undefined
3227
+
3228
+ const prop = metaArg.members.find(
3229
+ (member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "cache",
3230
+ )
3231
+ if (!prop || !ts.isPropertySignature(prop) || !prop.type) return undefined
3232
+ if (!ts.isTypeLiteralNode(prop.type)) return undefined
3233
+
3234
+ const parsed: ParsedModelCache = {}
3235
+ for (const member of prop.type.members) {
3236
+ if (!ts.isPropertySignature(member) || !member.type) continue
3237
+ switch (getPropertyName(member.name)) {
3238
+ case "enabled":
3239
+ if (isBooleanLiteralType(member.type, true)) parsed.enabled = true
3240
+ else if (isBooleanLiteralType(member.type, false)) parsed.enabled = false
3241
+ break
3242
+ case "public":
3243
+ if (isBooleanLiteralType(member.type, true)) parsed.public = true
3244
+ else if (isBooleanLiteralType(member.type, false)) parsed.public = false
3245
+ break
3246
+ case "rows":
3247
+ if (isBooleanLiteralType(member.type, true)) parsed.rows = true
3248
+ else if (isBooleanLiteralType(member.type, false)) parsed.rows = false
3249
+ break
3250
+ case "maxTtl": {
3251
+ const ttl = parseNumericTypeArg(member.type, sourceFile)
3252
+ if (ttl !== undefined) parsed.maxTtl = ttl
3253
+ break
3254
+ }
3255
+ }
3256
+ }
3257
+ // A `cache: {}` with nothing readable in it is not a declaration. Returning an empty object
3258
+ // would make the model "declared but permitting nothing", which reads the same as no block at
3259
+ // all and would put an empty entry in every manifest.
3260
+ return Object.keys(parsed).length > 0 ? parsed : undefined
3261
+ }
@@ -0,0 +1,180 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, 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 { freeTierCacheNote, seedApiConfigCache } from "../src/api-config-cache.js"
6
+
7
+ /**
8
+ * Two things have to be true for a cached read to be served: the schema declares the table (the
9
+ * ceiling) and the runtime allowlist has it on (what is active). A push writes the first. Without
10
+ * this it does not write the second, and the symptom is a declaration that appears to do nothing
11
+ * and a BYPASS header nobody asked about.
12
+ */
13
+
14
+ const dirs: string[] = []
15
+ afterEach(() => {
16
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
17
+ })
18
+
19
+ function project(apiConfig: unknown | null): string {
20
+ const dir = mkdtempSync(join(tmpdir(), "supatype-seed-"))
21
+ dirs.push(dir)
22
+ if (apiConfig !== null) {
23
+ mkdirSync(join(dir, ".supatype"), { recursive: true })
24
+ writeFileSync(
25
+ join(dir, ".supatype", "api-config.json"),
26
+ typeof apiConfig === "string" ? apiConfig : JSON.stringify(apiConfig, null, 2),
27
+ "utf8",
28
+ )
29
+ }
30
+ return dir
31
+ }
32
+
33
+ /** The file as it now stands, typed loosely because these assertions are about what is in it. */
34
+ const read = (dir: string): { rest?: { cache_tables?: Record<string, unknown>; cache_max_ttl?: unknown } } =>
35
+ JSON.parse(readFileSync(join(dir, ".supatype", "api-config.json"), "utf8")) as {
36
+ rest?: { cache_tables?: Record<string, unknown>; cache_max_ttl?: unknown }
37
+ }
38
+
39
+ const ast = (cache: unknown, table = "posts") => ({
40
+ models: [{ name: "Post", annotations: { db: { tableName: table }, platform: { cache } } }],
41
+ })
42
+
43
+ const config = (rest: Record<string, unknown>) => ({
44
+ rest: { schema: "public", max_rows: 1000, ...rest },
45
+ graphql: { introspection: true },
46
+ })
47
+
48
+ describe("what a push switches on", () => {
49
+ it("turns on a table the schema has just started declaring", () => {
50
+ const dir = project(config({ cache_max_ttl: 60, cache_tables: {} }))
51
+
52
+ const result = seedApiConfigCache(dir, ast({ enabled: true }))
53
+
54
+ expect(result?.seeded).toEqual(["posts"])
55
+ expect(read(dir).rest?.cache_tables).toEqual({ posts: { enabled: true, allow_public: false } })
56
+ })
57
+
58
+ it("follows the schema on public rather than defaulting it off", () => {
59
+ // `public` in the schema is a considered statement that this table's reads are the same for
60
+ // everyone, and push has already refused it where the read rule varies by caller. Defaulting
61
+ // it off would make the declaration mean nothing until someone ticked the box it justified.
62
+ const dir = project(config({ cache_max_ttl: 60, cache_tables: {} }))
63
+
64
+ seedApiConfigCache(dir, ast({ enabled: true, public: true }))
65
+
66
+ expect(read(dir).rest?.cache_tables).toEqual({ posts: { enabled: true, allow_public: true } })
67
+ })
68
+
69
+ it("never rewrites an entry that already exists", () => {
70
+ // `push resets to declared` is the alternative §13.2 rejects by name: an operator narrows a
71
+ // table during an incident, someone pushes an unrelated model, and the mitigation is reverted
72
+ // at the worst possible moment with nothing in the output to say so.
73
+ const dir = project(config({ cache_max_ttl: 60, cache_tables: { posts: { enabled: false, allow_public: false } } }))
74
+
75
+ const result = seedApiConfigCache(dir, ast({ enabled: true, public: true }))
76
+
77
+ expect(result?.seeded).toEqual([])
78
+ expect(read(dir).rest?.cache_tables).toEqual({ posts: { enabled: false, allow_public: false } })
79
+ })
80
+
81
+ it("leaves a hard opt-out switched off", () => {
82
+ // `enabled: false` is the schema declining, so there is nothing to turn on.
83
+ const dir = project(config({ cache_max_ttl: 60, cache_tables: {} }))
84
+
85
+ const result = seedApiConfigCache(dir, ast({ enabled: false }))
86
+
87
+ expect(result?.seeded).toEqual([])
88
+ expect(read(dir).rest?.cache_tables).toEqual({})
89
+ })
90
+
91
+ it("leaves an entry whose declaration has gone, and does not need to remove it", () => {
92
+ // The server intersects the allowlist with the declaration on every request, so an entry with
93
+ // nothing declaring it caches nothing. Removing it would be tidier and would also mean a push
94
+ // editing entries it did not create.
95
+ const dir = project(config({ cache_max_ttl: 60, cache_tables: { legacy: { enabled: true, allow_public: false } } }))
96
+
97
+ seedApiConfigCache(dir, ast({ enabled: true }))
98
+
99
+ const tables = read(dir).rest?.cache_tables ?? {}
100
+ expect(tables["legacy"]).toEqual({ enabled: true, allow_public: false })
101
+ expect(tables["posts"]).toEqual({ enabled: true, allow_public: false })
102
+ })
103
+ })
104
+
105
+ describe("what it reports rather than fixes", () => {
106
+ it("says so when the project-wide TTL is off", () => {
107
+ // Zero is an off switch someone may have chosen. A push that turned it on would be overriding
108
+ // a decision rather than filling in a blank, so it is reported where the person who can decide
109
+ // is reading.
110
+ const dir = project(config({ cache_max_ttl: 0, cache_tables: {} }))
111
+
112
+ const result = seedApiConfigCache(dir, ast({ enabled: true }))
113
+
114
+ expect(result?.ttlIsOff).toBe(true)
115
+ expect(read(dir).rest?.cache_max_ttl).toBe(0)
116
+ })
117
+
118
+ it("says nothing about the TTL when no table declares a cache", () => {
119
+ const dir = project(config({ cache_max_ttl: 0, cache_tables: {} }))
120
+ expect(seedApiConfigCache(dir, ast(undefined))?.ttlIsOff).toBe(false)
121
+ })
122
+ })
123
+
124
+ describe("files it will not touch", () => {
125
+ it("does nothing where there is no local server to configure", () => {
126
+ // A cloud-only project, or a stack that has never been started. Not an error: `dev` creates it.
127
+ expect(seedApiConfigCache(project(null), ast({ enabled: true }))).toBeNull()
128
+ })
129
+
130
+ it("leaves a malformed file exactly as it found it", () => {
131
+ // The server reports this far more clearly than a push can, and rewriting it would destroy
132
+ // whatever the author was in the middle of.
133
+ const dir = project("{ this is not json")
134
+ expect(seedApiConfigCache(dir, ast({ enabled: true }))).toBeNull()
135
+ expect(readFileSync(join(dir, ".supatype", "api-config.json"), "utf8")).toBe("{ this is not json")
136
+ })
137
+
138
+ it("does not invent a rest section in a file that has none", () => {
139
+ const dir = project({ graphql: { introspection: true } })
140
+ expect(seedApiConfigCache(dir, ast({ enabled: true }))).toBeNull()
141
+ expect(read(dir).rest).toBeUndefined()
142
+ })
143
+ })
144
+
145
+ describe("the free-tier note", () => {
146
+ // §13.4: a free project with `cache` in its schema has asked for the paid feature in code, which
147
+ // is better qualified than any banner — and it is why the note names the tables. "Your cache is
148
+ // off" is a fact about the plan; "posts is served from PostgREST on every request" is a fact
149
+ // about their project.
150
+ it("names the tables rather than the feature", () => {
151
+ expect(freeTierCacheNote({ tables: ["posts"], honoured: false })).toContain(
152
+ "posts declare a cache in your schema",
153
+ )
154
+ expect(freeTierCacheNote({ tables: ["posts", "profiles"], honoured: false })).toContain(
155
+ "posts and profiles",
156
+ )
157
+ expect(freeTierCacheNote({ tables: ["a", "b", "c"], honoured: false })).toContain("a, b and c")
158
+ })
159
+
160
+ it("says the declaration was kept, because it was", () => {
161
+ // The tier decides what runs, not what a schema may say. A note that read as a rejection would
162
+ // invite someone to delete a correct declaration.
163
+ expect(freeTierCacheNote({ tables: ["posts"], honoured: false })).toContain("takes effect on a paid plan")
164
+ })
165
+
166
+ it("says nothing on a tier that honours the declaration", () => {
167
+ expect(freeTierCacheNote({ tables: ["posts"], honoured: true })).toBeNull()
168
+ })
169
+
170
+ it("says nothing when the schema declared nothing", () => {
171
+ expect(freeTierCacheNote({ tables: [], honoured: false })).toBeNull()
172
+ })
173
+
174
+ it("says nothing when nothing was reported at all", () => {
175
+ // An engine push, or a control plane too old to answer. Absence is a question that was never
176
+ // asked, and the only honest response to it is silence rather than an upsell.
177
+ expect(freeTierCacheNote(undefined)).toBeNull()
178
+ expect(freeTierCacheNote({})).toBeNull()
179
+ })
180
+ })
@@ -0,0 +1,61 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { describe, expect, it } from "vitest"
4
+ import { isIdentityDependent, type AccessRuleNode } from "../src/cache-identity-scope.js"
5
+
6
+ /**
7
+ * The corpus is shared with supatype-server's `internal/studiobootstrap.IsIdentityDependent`,
8
+ * which answers the same question at request time. Two implementations of one rule drift, and the
9
+ * drift is silent in both directions: a push that permits `public: true` where the runtime refuses
10
+ * to share is a feature that quietly does nothing; a push that permits it where the runtime *does*
11
+ * share is a data leak. Running both against one file is what makes the pair falsifiable.
12
+ */
13
+ interface Corpus {
14
+ cases: { name: string; rule: AccessRuleNode | null; identityDependent: boolean }[]
15
+ }
16
+
17
+ const corpus = JSON.parse(
18
+ readFileSync(join(__dirname, "fixtures", "identity-scope-corpus.json"), "utf8"),
19
+ ) as Corpus
20
+
21
+ describe("the identity-dependence rule, against the shared corpus", () => {
22
+ it.each(corpus.cases)("$name", ({ rule, identityDependent }) => {
23
+ expect(isIdentityDependent(rule)).toBe(identityDependent)
24
+ })
25
+
26
+ it("covers both answers, so a stuck implementation cannot pass", () => {
27
+ // A classifier hardcoded to `true` is the dangerous failure — it refuses every public cache
28
+ // and looks conservative while making the feature unusable. One hardcoded to `false` is the
29
+ // leak. The corpus has to be able to catch both.
30
+ const yes = corpus.cases.filter(c => c.identityDependent).length
31
+ expect(yes).toBeGreaterThan(5)
32
+ expect(corpus.cases.length - yes).toBeGreaterThan(5)
33
+ })
34
+ })
35
+
36
+ describe("the distinction the whole feature turns on", () => {
37
+ it("does not confuse row-dependence with caller-dependence", () => {
38
+ // `Lte<"published_at", Now>` changes which rows come back, and changes over time, and is the
39
+ // same for every caller. Refusing it would remove the case public caching exists for.
40
+ const publishedContent: AccessRuleNode = {
41
+ type: "compare",
42
+ left: { kind: "column" },
43
+ right: { kind: "now" },
44
+ }
45
+ expect(isIdentityDependent(publishedContent)).toBe(false)
46
+ })
47
+
48
+ it("catches the same shape when the operand is the caller", () => {
49
+ const ownRows: AccessRuleNode = {
50
+ type: "compare",
51
+ left: { kind: "column" },
52
+ right: { kind: "authUid" },
53
+ }
54
+ expect(isIdentityDependent(ownRows)).toBe(true)
55
+ })
56
+
57
+ it("fails closed on anything it does not recognise", () => {
58
+ // A rule type added to the language and not to this file must refuse sharing, not permit it.
59
+ expect(isIdentityDependent({ type: "aRuleInventedTomorrow" })).toBe(true)
60
+ })
61
+ })