@ultimat3/cli 22.15.0 → 24.0.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/CLAUDE.md +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
package/src/templates/policy.ts
CHANGED
|
@@ -45,7 +45,7 @@ ${permissionSet(feature)}
|
|
|
45
45
|
/** What a write invalidates and a read depends on — one tag, both directions. */
|
|
46
46
|
export const ${feature.camel}Tag = tag('${feature.kebab}');
|
|
47
47
|
|
|
48
|
-
/** What
|
|
48
|
+
/** What a ${feature.kebab} write decides on: the org of the row an action names. */
|
|
49
49
|
export interface ${feature.pascal}Scope {
|
|
50
50
|
readonly orgId: string;
|
|
51
51
|
}
|
|
@@ -54,13 +54,17 @@ export interface ${feature.pascal}Scope {
|
|
|
54
54
|
// never do this" from "you may, but not in that org" — an agent can act on the difference.
|
|
55
55
|
// The predicates below add tenancy only; the grant is never re-checked by hand.
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Read takes no org from its input: the repo's typed handle scopes every read to the actor's own
|
|
59
|
+
* org, so the tenancy rule here is that the actor HAS one. A second org check on an input field
|
|
60
|
+
* would be a second source of tenancy, and the one a caller could set.
|
|
61
|
+
*/
|
|
62
|
+
export const can${feature.pascal}Read = can(
|
|
59
63
|
'${feature.kebab}:read',
|
|
60
|
-
({ actor
|
|
64
|
+
({ actor }) => actor !== null && typeof actor.orgId === 'string' && actor.orgId !== '',
|
|
61
65
|
);
|
|
62
66
|
|
|
63
|
-
/** Write
|
|
67
|
+
/** Write compares the org the action's input names with the actor's — grant it apart from read. */
|
|
64
68
|
export const can${feature.pascal}Write = can<${feature.pascal}Scope>(
|
|
65
69
|
'${feature.kebab}:write',
|
|
66
70
|
({ actor, input }) => actor !== null && actor.orgId === input.orgId,
|
|
@@ -103,10 +107,13 @@ const reader = testActor('reader', { orgId: org, permissions: [read] }).actor;
|
|
|
103
107
|
const writer = testActor('writer', { orgId: org, permissions: [read, write] }).actor;
|
|
104
108
|
const outsider = testActor('outsider', { orgId: otherOrg, permissions: [read, write] }).actor;
|
|
105
109
|
|
|
106
|
-
unitTest('${feature.camel} read denies anonymous and
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
unitTest('${feature.camel} read denies anonymous and orgless actors', async () => {
|
|
111
|
+
// Which org's rows a reader gets is the handle's, not this rule's: the slice's query test reads
|
|
112
|
+
// as an actor in another org and gets none of these.
|
|
113
|
+
const orgless = testActor('orgless', { permissions: [read] }).actor;
|
|
114
|
+
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input: {} });
|
|
115
|
+
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: orgless, input: {} });
|
|
116
|
+
await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input: {} });
|
|
110
117
|
});
|
|
111
118
|
|
|
112
119
|
unitTest('${feature.camel} write denies the read-only actor', async () => {
|
package/src/templates/query.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// an unbounded live query is a memory leak that only shows up under load.
|
|
4
4
|
|
|
5
5
|
import type { FeatureTarget } from './entity';
|
|
6
|
+
import { sortedImports } from './imports';
|
|
6
7
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
8
|
import { names } from './naming';
|
|
8
|
-
import {
|
|
9
|
+
import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
|
|
10
|
+
import { sliceFoundation, sliceTakesScaffoldRow } from './slice-foundation';
|
|
9
11
|
import { wrapImport } from './wrap';
|
|
10
12
|
|
|
11
13
|
/** A live read always fans out fresh, so a TTL on it would only ever be dead configuration. */
|
|
@@ -28,18 +30,23 @@ ${wrapImport(
|
|
|
28
30
|
)}
|
|
29
31
|
import * as repo from '../repo';
|
|
30
32
|
|
|
33
|
+
// No \`orgId\` in the input, and none in the policy: \`repo.list\` reads through the typed handle,
|
|
34
|
+
// which scopes every read to the ACTOR's org, and a live window is keyed by the subscriber's
|
|
35
|
+
// tenant. One source of tenancy — a parameter would be a second one a caller could set.
|
|
31
36
|
export const ${name.camel} = query({
|
|
32
|
-
input: t.object({
|
|
37
|
+
input: t.object({ limit: t.number.default(50) }),
|
|
33
38
|
policy: can${feature.pascal}Read,
|
|
34
39
|
live: ${String(live)},${cacheLine(feature, live)}
|
|
35
40
|
// Opt-in, unlike an action's tool: a read hands rows to an agent, so silence exposes nothing.
|
|
36
41
|
mcp: { expose: true, description: '${name.raw} — edit this description' },
|
|
37
|
-
sql: ({
|
|
42
|
+
sql: ({ limit }) =>
|
|
38
43
|
// \`feature.table\`, not the kebab plural: \`from()\` quotes the identifier into the SQL text,
|
|
39
44
|
// and the entity created the table as snake_case.
|
|
40
|
-
from<${feature.pascal}>('${feature.table}', () => repo.
|
|
41
|
-
.
|
|
42
|
-
|
|
45
|
+
from<${feature.pascal}>('${feature.table}', () => repo.list(limit))
|
|
46
|
+
// Newest first — \`repo.list\`'s own order. These two lines are ONE read spelled twice: the
|
|
47
|
+
// thunk is what the in-memory driver answers and this chain is the SQL Postgres runs, so a
|
|
48
|
+
// direction that differs here is a bounded page holding different rows on each.
|
|
49
|
+
.orderBy('createdAt', 'desc')
|
|
43
50
|
// The primary key last is what makes the order TOTAL: \`createdAt\` alone ties, and two
|
|
44
51
|
// rows that tie can swap between evaluations — a bounded read then drops one and repeats
|
|
45
52
|
// the other, and a live subscription patches a row it never sent.
|
|
@@ -48,29 +55,42 @@ export const ${name.camel} = query({
|
|
|
48
55
|
});
|
|
49
56
|
`;
|
|
50
57
|
|
|
51
|
-
const queryTest = (name: NameSet, feature: NameSet,
|
|
58
|
+
const queryTest = (name: NameSet, feature: NameSet, shape: ReadShape): string => {
|
|
59
|
+
const { live } = shape;
|
|
52
60
|
const wrapper = live ? 'liveTest' : 'unitTest';
|
|
61
|
+
// A one-shot query's file carries the read cases too, so it imports what they need; a live
|
|
62
|
+
// query's declaration suite reads nothing and imports only its own.
|
|
63
|
+
const subscribes = live && shape.storesRow;
|
|
64
|
+
const imports = subscribes
|
|
65
|
+
? [
|
|
66
|
+
"import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
|
|
67
|
+
`import { afterEach, expect, ${wrapper} } from '@ultimat3/testing';`,
|
|
68
|
+
`import { driver } from '${shape.dbModule}';`,
|
|
69
|
+
]
|
|
70
|
+
: live
|
|
71
|
+
? [`import { expect, ${wrapper} } from '@ultimat3/testing';`]
|
|
72
|
+
: readImports(shape, ['expect', wrapper]);
|
|
73
|
+
const repo = shape.storesRow
|
|
74
|
+
? `${subscribes ? `\nimport type { ${feature.pascal} } from '../entity';` : ''}\nimport * as repo from '../repo';`
|
|
75
|
+
: '';
|
|
53
76
|
return `// ${name.camel}: the shape it reads, the policy it asserts, and the actor it refuses. The read is
|
|
54
77
|
// declarative, so what a test can get wrong is the authz, and that is what this pins.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
import {
|
|
78
|
+
${sortedImports([
|
|
79
|
+
...imports,
|
|
80
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
81
|
+
subscribes
|
|
82
|
+
? "import { registerQuery, sourceFor } from '@ultimat3/query';"
|
|
83
|
+
: "import { sourceFor } from '@ultimat3/query';",
|
|
84
|
+
])}${repo}
|
|
58
85
|
import { ${name.camel} } from './${name.kebab}';
|
|
59
|
-
|
|
60
|
-
// A real v4 uuid: the read parses its input the way a request does, so a placeholder that only
|
|
61
|
-
// looks like a uuid would fail before it ever built any SQL.
|
|
62
|
-
const orgId = '00000000-0000-4000-8000-000000000002';
|
|
63
|
-
|
|
86
|
+
${live && !shape.storesRow ? '' : ORG_LINE}
|
|
64
87
|
// Named here because every projection needs a stable name and this file does not boot the app.
|
|
65
88
|
// At boot \`registerQueries(await import('./${live ? 'live' : 'queries'}'))\` stamps the same name
|
|
66
89
|
// onto the same object.
|
|
67
90
|
const target = ${name.camel}.named('${name.camel}');
|
|
68
91
|
|
|
69
|
-
// Holds the grant
|
|
70
|
-
const
|
|
71
|
-
orgId: '00000000-0000-4000-8000-000000000009',
|
|
72
|
-
permissions: ['${feature.kebab}:read'],
|
|
73
|
-
}).actor;
|
|
92
|
+
// Holds the grant and no org — so a denial here is the predicate deciding, not the grant.
|
|
93
|
+
const orgless = testActor('orgless', { permissions: ['${feature.kebab}:read'] }).actor;
|
|
74
94
|
|
|
75
95
|
${wrapper}('${name.camel} is a declared ${live ? 'live ' : ''}query', () => {
|
|
76
96
|
expect(target.kind).toBe('query');
|
|
@@ -85,7 +105,7 @@ ${wrapper}('${name.camel} is bounded and TOTALLY ordered', async () => {
|
|
|
85
105
|
// written reason, never a boolean, because a boolean reads exactly like forgetting the policy.
|
|
86
106
|
const source = await sourceFor(
|
|
87
107
|
target,
|
|
88
|
-
{
|
|
108
|
+
{ limit: 50 },
|
|
89
109
|
{
|
|
90
110
|
actor: null,
|
|
91
111
|
unenforced: 'a scaffolded test asserts the SQL text; the policy is asserted separately',
|
|
@@ -93,17 +113,18 @@ ${wrapper}('${name.camel} is bounded and TOTALLY ordered', async () => {
|
|
|
93
113
|
);
|
|
94
114
|
const { sql } = source.toSQL();
|
|
95
115
|
const text = sql.toLowerCase();
|
|
96
|
-
|
|
116
|
+
// Newest first, as \`repo.list\` — the in-memory half of this same read — returns them.
|
|
117
|
+
expect(text).toContain('order by "createdat" desc');
|
|
97
118
|
expect(text).toContain('limit');
|
|
98
119
|
// "ordered" is not enough. Dropping the id tiebreak still leaves an ORDER BY, so asserting on
|
|
99
120
|
// its presence alone would keep passing while the read went non-deterministic under ties.
|
|
100
121
|
expect(text.slice(text.lastIndexOf('order by'))).toContain('id');
|
|
101
122
|
});
|
|
102
123
|
|
|
103
|
-
${wrapper}('${name.camel} denies
|
|
124
|
+
${wrapper}('${name.camel} denies an orgless actor before a read', async () => {
|
|
104
125
|
// \`.as()\` is the one read path with the actor swapped: validate, authorize, then read. The
|
|
105
126
|
// denial lands before any SQL executes, which is why this needs no database.
|
|
106
|
-
const denied = await target.as(
|
|
127
|
+
const denied = await target.as(orgless, { limit: 50 }).catch((error: unknown) => error);
|
|
107
128
|
expect(denied).toBeUltimateError('X_FORBIDDEN');
|
|
108
129
|
});
|
|
109
130
|
|
|
@@ -112,11 +133,195 @@ ${wrapper}('${name.camel} exposes one MCP tool that reads, and never writes', ()
|
|
|
112
133
|
expect(target.tool().policy).toBe(target.policy);
|
|
113
134
|
expect(target.tool().mutates).toBe(false);
|
|
114
135
|
});
|
|
136
|
+
${subscribes ? subscribeTest(name, feature) : ''}`;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The REAL subscribe path, with two orgs: a sync node in this process (the `subscribe` fixture),
|
|
141
|
+
* never `.as(actor)`. `.as()` installs a request context of its own, so a live source that
|
|
142
|
+
* leaves its tenant to the acting actor — which is what the query above does, through
|
|
143
|
+
* `repo.list` — read fine there while every real subscriber was answered `X_TENANCY_UNSCOPED`.
|
|
144
|
+
* Two orgs, a snapshot and a write: the window a subscriber is served from is its own org's.
|
|
145
|
+
*/
|
|
146
|
+
const subscribeTest = (name: NameSet, feature: NameSet): string => `
|
|
147
|
+
// The module's own export under its own name — what \`defineApi\` does at boot — so a subscribe
|
|
148
|
+
// frame naming it reaches this declaration.
|
|
149
|
+
const live = registerQuery('${name.camel}', ${name.camel});
|
|
150
|
+
|
|
151
|
+
type Row = ${feature.pascal};
|
|
152
|
+
|
|
153
|
+
const otherOrg = '${OTHER_ORG}';
|
|
154
|
+
|
|
155
|
+
const readerOf = (org: string) =>
|
|
156
|
+
testActor('reader', { orgId: org, permissions: ['${feature.kebab}:read'] }).actor;
|
|
157
|
+
|
|
158
|
+
// The request clock, so \`createdAt\` is an instant this file chose and "newest" is decidable.
|
|
159
|
+
const clock = frozenClock('2026-01-01T00:00:00.000Z');
|
|
160
|
+
|
|
161
|
+
const store = (org: string, title: string) => {
|
|
162
|
+
const draft = { orgId: org, title, price: { minor: 0, currency: 'USD' } };
|
|
163
|
+
const writer = createContext({ actor: testActor('writer', { orgId: org }).actor, clock });
|
|
164
|
+
return runWithContext(writer, () => repo.insert(draft));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
168
|
+
afterEach(() => {
|
|
169
|
+
driver.reset?.();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
liveTest('two orgs subscribe: each is sent its own rows and writes', async ({ subscribe }) => {
|
|
173
|
+
await store(orgId, 'ours');
|
|
174
|
+
await store(otherOrg, 'theirs');
|
|
175
|
+
// The REAL subscribe path — a sync node in this process — and never \`.as(actor)\`: that
|
|
176
|
+
// installs a request context of its own, so a source read with no tenant passes there and is
|
|
177
|
+
// X_TENANCY_UNSCOPED for every real subscriber.
|
|
178
|
+
// ONE input for both: which org a window holds is the subscriber's, never a parameter.
|
|
179
|
+
const input = { limit: 50 };
|
|
180
|
+
const ours = await subscribe<Row>(live, input, readerOf(orgId));
|
|
181
|
+
const theirs = await subscribe<Row>(live, input, readerOf(otherOrg));
|
|
182
|
+
expect(ours.rows().map((row) => row.title)).toEqual(['ours']);
|
|
183
|
+
expect(theirs.rows().map((row) => row.title)).toEqual(['theirs']);
|
|
184
|
+
|
|
185
|
+
// A write after the snapshot reaches its own org's subscriber as a patch — placed first, the
|
|
186
|
+
// order the declaration names — and nobody else's.
|
|
187
|
+
clock.advance(1000);
|
|
188
|
+
await store(orgId, 'ours too');
|
|
189
|
+
await ours.settled();
|
|
190
|
+
await theirs.settled();
|
|
191
|
+
expect(ours.rows().map((row) => row.title)).toEqual(['ours too', 'ours']);
|
|
192
|
+
expect(theirs.rows().map((row) => row.title)).toEqual(['theirs']);
|
|
193
|
+
expect(theirs.patches()).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
`;
|
|
196
|
+
|
|
197
|
+
const ORG = '00000000-0000-4000-8000-000000000002';
|
|
198
|
+
|
|
199
|
+
/** The org the reader and its rows belong to — an actor's, never the read's input. */
|
|
200
|
+
const ORG_LINE = `
|
|
201
|
+
// The org the reader and the stored rows belong to: the ACTOR's, never a field of the input.
|
|
202
|
+
const orgId = '${ORG}';
|
|
115
203
|
`;
|
|
204
|
+
const OTHER_ORG = '00000000-0000-4000-8000-000000000009';
|
|
205
|
+
|
|
206
|
+
/** What the read test may do with the slice the query reads. */
|
|
207
|
+
interface ReadShape {
|
|
208
|
+
readonly live: boolean;
|
|
209
|
+
/** The slice still takes the row `x g entity` scaffolds, so the test may store some. */
|
|
210
|
+
readonly storesRow: boolean;
|
|
211
|
+
readonly dbModule: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Rows, stored and read back: the case that runs the thunk AND the order the declaration puts on
|
|
216
|
+
* it. Three rows a second apart, a page of two — so an order that is not newest-first, a missing
|
|
217
|
+
* bound and another org's row each fail it on their own.
|
|
218
|
+
*/
|
|
219
|
+
const storedRowsTest = (): string => `
|
|
220
|
+
const otherOrg = '${OTHER_ORG}';
|
|
221
|
+
|
|
222
|
+
// The request clock, so \`createdAt\` is an instant this file chose and "newest" is decidable.
|
|
223
|
+
const clock = frozenClock('2026-01-01T00:00:00.000Z');
|
|
224
|
+
|
|
225
|
+
const store = (owner: string, title: string) => {
|
|
226
|
+
const draft = { orgId: owner, title, price: { minor: 0, currency: 'USD' } };
|
|
227
|
+
const writer = createContext({ actor: testActor('writer', { orgId: owner }).actor, clock });
|
|
228
|
+
return runWithContext(writer, () => repo.insert(draft));
|
|
116
229
|
};
|
|
117
230
|
|
|
231
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
232
|
+
afterEach(() => {
|
|
233
|
+
driver.reset?.();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
unitTest('stored rows read back newest first, bounded, and never another org', async () => {
|
|
237
|
+
for (const title of ['first', 'second', 'third']) {
|
|
238
|
+
await store(orgId, title);
|
|
239
|
+
clock.advance(1000);
|
|
240
|
+
}
|
|
241
|
+
await store(otherOrg, 'theirs');
|
|
242
|
+
// The declaration's order over the repo's rows: what the SQL names on Postgres, read here
|
|
243
|
+
// from the in-memory driver. A page of two is the two NEWEST, on both.
|
|
244
|
+
const page = await target.as(member, { limit: 2 });
|
|
245
|
+
expect(page.map((row) => row.title)).toEqual(['third', 'second']);
|
|
246
|
+
// The same input, an actor in the other org: its own row, and nothing of this org's.
|
|
247
|
+
const outsider = testActor('outsider', { orgId: otherOrg, permissions: [read] }).actor;
|
|
248
|
+
const theirs = await target.as(outsider, { limit: 50 });
|
|
249
|
+
expect(theirs.map((row) => row.title)).toEqual(['theirs']);
|
|
250
|
+
});
|
|
251
|
+
`;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The READ, executed — the half of a query a declaration test never runs. `.as()` is the one read
|
|
255
|
+
* path: parse, authorize, then read through the repo, which under `bun test` is the in-memory
|
|
256
|
+
* driver. For a live query this is its own file, because the `.live.test.ts` beside it belongs to
|
|
257
|
+
* the `live` step and an app's coverage floor counts the unit suite alone.
|
|
258
|
+
*
|
|
259
|
+
* Rows are stored only in a slice whose row this generator can spell (`sliceTakesScaffoldRow`):
|
|
260
|
+
* one an author reshaped has its own columns, and the refusal and the empty read need none.
|
|
261
|
+
*
|
|
262
|
+
* Every actor is `testActor(…).actor` — the one fixture builder, and an `Actor`, so the stored-row
|
|
263
|
+
* case builds its context from it directly.
|
|
264
|
+
*/
|
|
265
|
+
const readCases = (
|
|
266
|
+
feature: NameSet,
|
|
267
|
+
shape: ReadShape,
|
|
268
|
+
): string => `// The read grant, named once so every line below fits the formatter's width at any feature name.
|
|
269
|
+
const read = '${feature.kebab}:read';
|
|
270
|
+
|
|
271
|
+
// A member of the org, holding the read grant and nothing else.
|
|
272
|
+
const member = testActor('member', { orgId, permissions: [read] }).actor;
|
|
273
|
+
|
|
274
|
+
unitTest('a caller with no grant is refused before the read', async () => {
|
|
275
|
+
const stranger = testActor('stranger', { orgId }).actor;
|
|
276
|
+
const refused = await target.as(stranger, { limit: 50 }).catch((error: unknown) => error);
|
|
277
|
+
expect(refused).toBeUltimateError('X_FORBIDDEN');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
unitTest('the read runs as its caller: an org with no rows reads empty', async () => {
|
|
281
|
+
// Through the policy, the source and the repo — every line of the declaration, against the
|
|
282
|
+
// in-memory driver.
|
|
283
|
+
expect(await target.as(member, { limit: 50 })).toEqual([]);
|
|
284
|
+
});
|
|
285
|
+
${shape.storesRow ? storedRowsTest() : ''}`;
|
|
286
|
+
|
|
287
|
+
/** The imports the read cases need, over whatever the file they land in already imports. */
|
|
288
|
+
const readImports = (shape: ReadShape, testing: readonly string[]): readonly string[] => [
|
|
289
|
+
...(shape.storesRow
|
|
290
|
+
? ["import { createContext, frozenClock, runWithContext } from '@ultimat3/core';"]
|
|
291
|
+
: []),
|
|
292
|
+
`import { ${[...(shape.storesRow ? ['afterEach'] : []), ...testing].join(', ')} } from '@ultimat3/testing';`,
|
|
293
|
+
...(shape.storesRow ? [`import { driver } from '${shape.dbModule}';`] : []),
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
/** A live query's unit half, as its own file: the suite beside it runs on the `live` step. */
|
|
297
|
+
const liveReadTest = (
|
|
298
|
+
name: NameSet,
|
|
299
|
+
feature: NameSet,
|
|
300
|
+
shape: ReadShape,
|
|
301
|
+
): string => `// ${name.camel}, READ: what a caller gets back, through the repo and the in-memory driver. The
|
|
302
|
+
// subscription — the snapshot and the patches a write produces — is the live suite's, next door.
|
|
303
|
+
${sortedImports([...readImports(shape, ['expect', 'unitTest']), "import { testActor } from '@ultimat3/policy';"])}${shape.storesRow ? "\nimport * as repo from '../repo';" : ''}
|
|
304
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
305
|
+
|
|
306
|
+
const orgId = '${ORG}';
|
|
307
|
+
|
|
308
|
+
// Named here because every projection needs a stable name and this file does not boot the app.
|
|
309
|
+
const target = ${name.camel}.named('${name.camel}');
|
|
310
|
+
|
|
311
|
+
${readCases(feature, shape)}`;
|
|
312
|
+
|
|
118
313
|
export interface QueryOptions extends FeatureTarget {
|
|
119
314
|
readonly live?: boolean;
|
|
315
|
+
/** Every locale the planted entity's admin labels ship for. Defaults to `['en']`. */
|
|
316
|
+
readonly locales?: readonly string[];
|
|
317
|
+
/**
|
|
318
|
+
* The slice's `entity.ts`, or absent when the feature has none yet. Decides one thing: whether
|
|
319
|
+
* the unit test may STORE rows (`sliceTakesScaffoldRow`). `x g resource` passes the entity it is
|
|
320
|
+
* writing in the same run; `x g query` reads the one on disk.
|
|
321
|
+
*/
|
|
322
|
+
readonly sliceEntity?: string;
|
|
323
|
+
/** The slice's `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
|
|
324
|
+
readonly sliceRepo?: string;
|
|
120
325
|
}
|
|
121
326
|
|
|
122
327
|
export function queryFiles(rawName: string, target: QueryOptions): readonly GeneratedFile[] {
|
|
@@ -124,17 +329,28 @@ export function queryFiles(rawName: string, target: QueryOptions): readonly Gene
|
|
|
124
329
|
const feature = names(target.feature);
|
|
125
330
|
const live = target.live === true;
|
|
126
331
|
const dir = `${target.surfaceDir}/${target.feature}/${live ? 'live' : 'queries'}`;
|
|
332
|
+
const shape: ReadShape = {
|
|
333
|
+
live,
|
|
334
|
+
storesRow: sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo),
|
|
335
|
+
dbModule: target.dbModule ?? PLACEHOLDER_DB_MODULE,
|
|
336
|
+
};
|
|
127
337
|
return [
|
|
128
338
|
// A read declares the row type it returns (`../entity`), the rule that admits it (`../policy`)
|
|
129
339
|
// and the one module allowed to query the table (`../repo`) — all three are the slice's, and
|
|
130
340
|
// `--live` changes only which directory this file lands in.
|
|
131
|
-
...sliceFoundation(target, ['entity', 'policy']),
|
|
341
|
+
...sliceFoundation(target, ['entity', 'policy'], target.locales),
|
|
132
342
|
{ path: `${dir}/${name.kebab}.ts`, contents: querySource(name, feature, live) },
|
|
133
343
|
// The suffix follows the WRAPPER, which follows `--live`: a `liveTest` in a plain
|
|
134
344
|
// `<name>.test.ts` runs under `unit`, so `x test live` had no files in an app full of them.
|
|
135
345
|
{
|
|
136
346
|
path: `${dir}/${name.kebab}${live ? '.live' : ''}.test.ts`,
|
|
137
|
-
contents:
|
|
347
|
+
contents: live
|
|
348
|
+
? queryTest(name, feature, shape)
|
|
349
|
+
: `${queryTest(name, feature, shape)}\n${readCases(feature, shape)}`,
|
|
138
350
|
},
|
|
351
|
+
// A live read's unit half, in its own file: the suite above runs on the `live` step.
|
|
352
|
+
...(live
|
|
353
|
+
? [{ path: `${dir}/${name.kebab}.test.ts`, contents: liveReadTest(name, feature, shape) }]
|
|
354
|
+
: []),
|
|
139
355
|
];
|
|
140
356
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// input was `{ id, orgId }` while the form posted `{ title }`. The input is the entity's own view of
|
|
4
4
|
// the columns a caller supplies; the org comes from the actor, never from the body.
|
|
5
5
|
|
|
6
|
+
import { sortedImports } from './imports';
|
|
6
7
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
8
|
import { names } from './naming';
|
|
8
9
|
import { wrapImport, wrapList } from './wrap';
|
|
@@ -17,6 +18,7 @@ const createSource = (
|
|
|
17
18
|
// the list below — and never a second schema to keep in step.
|
|
18
19
|
|
|
19
20
|
import { action } from '@ultimat3/action';
|
|
21
|
+
import type { Actor } from '@ultimat3/core';
|
|
20
22
|
import { tenancyActorOrgRequired } from '@ultimat3/entity';
|
|
21
23
|
${wrapImport([`${feature.pascal}View`, feature.camel], '../entity')}
|
|
22
24
|
${wrapImport([`can${feature.pascal}Create`, `${feature.camel}Tag`], '../policy')}
|
|
@@ -30,6 +32,21 @@ ${wrapList(
|
|
|
30
32
|
']);',
|
|
31
33
|
)}
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The org a row is written under: the actor's, never the body's. The policy already refused an
|
|
37
|
+
* actor with none, so inside \`handle\` this is that fact for the type checker — and the
|
|
38
|
+
* framework's own refusal if a caller ever reaches the service around the policy.
|
|
39
|
+
*/
|
|
40
|
+
export const orgOf = (actor: Actor): string => {
|
|
41
|
+
if (typeof actor.orgId === 'string' && actor.orgId !== '') return actor.orgId;
|
|
42
|
+
throw tenancyActorOrgRequired({
|
|
43
|
+
entityName: '${feature.camel}',
|
|
44
|
+
operation: 'create',
|
|
45
|
+
actorId: actor.id,
|
|
46
|
+
actorKind: actor.kind,
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
33
50
|
export const create${feature.pascal} = action({
|
|
34
51
|
input: Create${feature.pascal}Input,
|
|
35
52
|
output: ${feature.pascal}View,
|
|
@@ -38,18 +55,7 @@ export const create${feature.pascal} = action({
|
|
|
38
55
|
// No \`mcp\`: an MCP tool's description IS what an agent reads to decide to call it, and a
|
|
39
56
|
// placeholder there is worse than no tool. Write one, then \`mcp: { expose: true, description }\`.
|
|
40
57
|
async handle({ input, ctx }) {
|
|
41
|
-
|
|
42
|
-
// fact for the type checker, and the framework's own refusal if a caller skips the policy.
|
|
43
|
-
const orgId = ctx.actor.orgId;
|
|
44
|
-
if (typeof orgId !== 'string' || orgId === '') {
|
|
45
|
-
throw tenancyActorOrgRequired({
|
|
46
|
-
entityName: '${feature.camel}',
|
|
47
|
-
operation: 'create',
|
|
48
|
-
actorId: ctx.actor.id,
|
|
49
|
-
actorKind: ctx.actor.kind,
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
return service.create({ ...input, orgId });
|
|
58
|
+
return service.create({ ...input, orgId: orgOf(ctx.actor) });
|
|
53
59
|
},
|
|
54
60
|
});
|
|
55
61
|
`;
|
|
@@ -58,13 +64,29 @@ const ORG = '00000000-0000-4000-8000-000000000002';
|
|
|
58
64
|
|
|
59
65
|
const createUnitTest = (
|
|
60
66
|
feature: NameSet,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
dbModule: string,
|
|
68
|
+
): string => `// create${feature.pascal}: its declared shape, the input it refuses, and the row its handler
|
|
69
|
+
// writes — against the in-memory driver, so all three belong to the \`unit\` step.
|
|
70
|
+
${sortedImports([
|
|
71
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
72
|
+
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
73
|
+
`import { driver } from '${dbModule}';`,
|
|
74
|
+
])}
|
|
75
|
+
import { create${feature.pascal}, orgOf } from './create-${feature.kebab}';
|
|
65
76
|
|
|
66
77
|
const target = create${feature.pascal}.named('create${feature.pascal}');
|
|
67
78
|
const input = { title: 'A ${feature.kebab}', price: { minor: 1200, currency: 'USD' } };
|
|
79
|
+
const orgId = '${ORG}';
|
|
80
|
+
|
|
81
|
+
// Holds the grant and an org: past the policy, so what answers is the handler.
|
|
82
|
+
const grant = ['${feature.kebab}:write'];
|
|
83
|
+
const writer = testActor('writer', { orgId, permissions: grant }).actor;
|
|
84
|
+
const orgless = testActor('orgless', { permissions: grant }).actor;
|
|
85
|
+
|
|
86
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
driver.reset?.();
|
|
89
|
+
});
|
|
68
90
|
|
|
69
91
|
unitTest('create${feature.pascal} is a declared action', () => {
|
|
70
92
|
expect(target.kind).toBe('action');
|
|
@@ -75,6 +97,24 @@ unitTest('create${feature.pascal} takes the columns a caller supplies', async ()
|
|
|
75
97
|
await expect(target.input).toAcceptInput(input);
|
|
76
98
|
await expect(target.input).toRejectInput({ price: input.price });
|
|
77
99
|
});
|
|
100
|
+
|
|
101
|
+
unitTest('the handler writes the row and answers its view', async () => {
|
|
102
|
+
const created = await target.as(writer, input);
|
|
103
|
+
expect(created).toMatchObject({ title: input.title, price: input.price });
|
|
104
|
+
// The view, not the row: the tenant is the caller's context, never the client's data.
|
|
105
|
+
expect(created).not.toHaveProperty('orgId');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
unitTest('orgOf answers the org of the actor, and refuses an actor with none', () => {
|
|
109
|
+
expect(orgOf(writer)).toBe(orgId);
|
|
110
|
+
let refused: unknown;
|
|
111
|
+
try {
|
|
112
|
+
orgOf(orgless);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
refused = error;
|
|
115
|
+
}
|
|
116
|
+
expect(refused).toBeUltimateError('X_TENANCY_ACTOR_ORG_REQUIRED');
|
|
117
|
+
});
|
|
78
118
|
`;
|
|
79
119
|
|
|
80
120
|
const createContractTest = (
|
|
@@ -113,12 +153,16 @@ contractTest('create${feature.pascal} projects one operation', () => {
|
|
|
113
153
|
`;
|
|
114
154
|
|
|
115
155
|
/** The create action and its two suites, under `<slice>/actions/create-<feature>`. */
|
|
116
|
-
export function resourceCreateFiles(
|
|
156
|
+
export function resourceCreateFiles(
|
|
157
|
+
rawName: string,
|
|
158
|
+
sliceDir: string,
|
|
159
|
+
dbModule: string,
|
|
160
|
+
): readonly GeneratedFile[] {
|
|
117
161
|
const feature = names(rawName);
|
|
118
162
|
const dir = `${sliceDir}/actions`;
|
|
119
163
|
return [
|
|
120
164
|
{ path: `${dir}/create-${feature.kebab}.ts`, contents: createSource(feature) },
|
|
121
|
-
{ path: `${dir}/create-${feature.kebab}.test.ts`, contents: createUnitTest(feature) },
|
|
165
|
+
{ path: `${dir}/create-${feature.kebab}.test.ts`, contents: createUnitTest(feature, dbModule) },
|
|
122
166
|
{
|
|
123
167
|
path: `${dir}/create-${feature.kebab}.contract.test.ts`,
|
|
124
168
|
contents: createContractTest(feature),
|