@supatype/cli 0.2.0 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +190 -153
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/api-config-cache.d.ts +34 -0
- package/dist/api-config-cache.d.ts.map +1 -0
- package/dist/api-config-cache.js +123 -0
- package/dist/api-config-cache.js.map +1 -0
- package/dist/cache-identity-scope.d.ts +38 -0
- package/dist/cache-identity-scope.d.ts.map +1 -0
- package/dist/cache-identity-scope.js +70 -0
- package/dist/cache-identity-scope.js.map +1 -0
- package/dist/cache-provider.d.ts +30 -0
- package/dist/cache-provider.d.ts.map +1 -0
- package/dist/cache-provider.js +60 -0
- package/dist/cache-provider.js.map +1 -0
- package/dist/cli-version-embedded.js +1 -1
- package/dist/commands/add.js +1 -1
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/cache.d.ts +1 -1
- package/dist/commands/cache.js +3 -3
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +2 -1
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +61 -8
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/diff.d.ts.map +1 -1
- package/dist/commands/diff.js +2 -1
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +6 -2
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +13 -9
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/keys.d.ts +14 -0
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +37 -0
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -1
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +52 -1
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/self-host.d.ts.map +1 -1
- package/dist/commands/self-host.js +9 -0
- package/dist/commands/self-host.js.map +1 -1
- package/dist/compose-services.d.ts.map +1 -1
- package/dist/compose-services.js +2 -1
- package/dist/compose-services.js.map +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +18 -7
- package/dist/dev-compose.js.map +1 -1
- package/dist/engine-floor.d.ts +11 -1
- package/dist/engine-floor.d.ts.map +1 -1
- package/dist/engine-floor.js +49 -16
- package/dist/engine-floor.js.map +1 -1
- package/dist/functions-deno-types.d.ts +9 -0
- package/dist/functions-deno-types.d.ts.map +1 -1
- package/dist/functions-deno-types.js +45 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/functions-router-gen.d.ts.map +1 -1
- package/dist/functions-router-gen.js +113 -81
- package/dist/functions-router-gen.js.map +1 -1
- package/dist/kong-config.d.ts +1 -1
- package/dist/model-cache.d.ts +17 -0
- package/dist/model-cache.d.ts.map +1 -0
- package/dist/model-cache.js +45 -0
- package/dist/model-cache.js.map +1 -0
- package/dist/model-hooks.d.ts +5 -0
- package/dist/model-hooks.d.ts.map +1 -1
- package/dist/model-hooks.js +29 -1
- package/dist/model-hooks.js.map +1 -1
- package/dist/model-versioning.d.ts +80 -0
- package/dist/model-versioning.d.ts.map +1 -0
- package/dist/model-versioning.js +140 -0
- package/dist/model-versioning.js.map +1 -0
- package/dist/postgres-ctl.d.ts +55 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +84 -8
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/preview-config-check.d.ts +21 -0
- package/dist/preview-config-check.d.ts.map +1 -0
- package/dist/preview-config-check.js +47 -0
- package/dist/preview-config-check.js.map +1 -0
- package/dist/project-config.d.ts +182 -5
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +125 -8
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +9 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js.map +1 -1
- package/dist/rest-cache-admin.d.ts.map +1 -1
- package/dist/rest-cache-admin.js +6 -2
- package/dist/rest-cache-admin.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +32 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +14 -1
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts +20 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +181 -27
- package/dist/self-host-compose.js.map +1 -1
- package/dist/studio-admin-roles.d.ts +8 -1
- package/dist/studio-admin-roles.d.ts.map +1 -1
- package/dist/studio-admin-roles.js +16 -2
- package/dist/studio-admin-roles.js.map +1 -1
- package/dist/type-extractor.d.ts +7 -0
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +234 -3
- package/dist/type-extractor.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +142 -0
- package/src/cache-identity-scope.ts +92 -0
- package/src/cache-provider.ts +61 -0
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/add.ts +1 -1
- package/src/commands/cache.ts +3 -3
- package/src/commands/cloud.ts +2 -1
- package/src/commands/deploy.ts +2 -1
- package/src/commands/dev.ts +68 -7
- package/src/commands/diff.ts +2 -1
- package/src/commands/functions.ts +6 -2
- package/src/commands/init.ts +13 -9
- package/src/commands/keys.ts +40 -0
- package/src/commands/migrate.ts +2 -1
- package/src/commands/push.ts +57 -1
- package/src/commands/self-host.ts +9 -0
- package/src/compose-services.ts +2 -1
- package/src/dev-compose.ts +18 -7
- package/src/engine-floor.ts +73 -16
- package/src/functions-deno-types.ts +48 -1
- package/src/functions-router-gen.ts +113 -81
- package/src/kong-config.ts +1 -1
- package/src/model-cache.ts +74 -0
- package/src/model-hooks.ts +26 -2
- package/src/model-versioning.ts +167 -0
- package/src/postgres-ctl.ts +103 -9
- package/src/preview-config-check.ts +52 -0
- package/src/project-config.ts +273 -11
- package/src/resolve-target.ts +11 -1
- package/src/rest-cache-admin.ts +8 -2
- package/src/schema-ast-v2.ts +47 -0
- package/src/self-host-compose.ts +204 -27
- package/src/studio-admin-roles.ts +16 -2
- package/src/type-extractor.ts +281 -2
- package/tests/api-config-cache-seed.test.ts +180 -0
- package/tests/cache-identity-scope.test.ts +61 -0
- package/tests/cache-provider-config.test.ts +132 -0
- package/tests/engine-floor.test.ts +43 -0
- package/tests/external-database-compose.test.ts +74 -3
- package/tests/field-masking-tier.test.ts +3 -3
- package/tests/fixtures/identity-scope-corpus.json +42 -0
- package/tests/functions-concurrency.test.ts +71 -0
- package/tests/init.test.ts +36 -2
- package/tests/model-cache-declaration.test.ts +229 -0
- package/tests/model-cache-manifest.test.ts +99 -0
- package/tests/model-hooks.test.ts +55 -0
- package/tests/model-versions.test.ts +229 -0
- package/tests/native-keyspace.test.ts +130 -0
- package/tests/preview-config-check.test.ts +104 -0
- package/tests/rest-cache-admin.test.ts +36 -0
- package/tests/runtime-contract.test.ts +176 -22
- package/tests/self-host-keys.test.ts +92 -0
- package/tests/studio-admin-roles.test.ts +32 -0
- package/tests/type-extractor.test.ts +113 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/type-extractor.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
+
})
|