@jini-ai/cms 0.3.11 → 0.4.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.
- package/CHANGELOG.md +59 -0
- package/README.md +47 -19
- package/dist/content-types/agent-tools.d.ts +3 -56
- package/dist/content-types/agent-tools.js +22 -6
- package/dist/content-types/cleanup.d.ts +18 -13
- package/dist/content-types/cleanup.js +16 -11
- package/dist/content-types/errors.d.ts +57 -24
- package/dist/content-types/errors.js +56 -39
- package/dist/content-types/field-defs.d.ts +8 -4
- package/dist/content-types/field-defs.js +19 -14
- package/dist/content-types/index-provisioning.d.ts +61 -47
- package/dist/content-types/index-provisioning.js +90 -66
- package/dist/content-types/index.d.ts +4 -3
- package/dist/content-types/lifecycle-dispatch.d.ts +11 -5
- package/dist/content-types/lifecycle-dispatch.js +5 -4
- package/dist/content-types/lifecycle.d.ts +23 -18
- package/dist/content-types/lifecycle.js +71 -64
- package/dist/content-types/repo.memory.d.ts +5 -4
- package/dist/content-types/repo.memory.js +3 -2
- package/dist/content-types/tool-registrations.d.ts +6 -8
- package/dist/content-types/tool-registrations.js +24 -28
- package/dist/content-types/types.d.ts +17 -13
- package/dist/content-types/types.js +11 -5
- package/dist/content-types/write-service.d.ts +20 -22
- package/dist/content-types/write-service.js +78 -75
- package/dist/core/authorization.d.ts +15 -0
- package/dist/core/authorization.js +2 -0
- package/dist/core/commands/change-set.d.ts +15 -7
- package/dist/core/commands/command.d.ts +45 -26
- package/dist/core/commands/command.js +73 -63
- package/dist/core/entity-liveness.d.ts +11 -7
- package/dist/core/entity-liveness.js +5 -4
- package/dist/core/index.d.ts +12 -5
- package/dist/core/index.js +10 -4
- package/dist/core/ports.d.ts +25 -24
- package/dist/core/ports.js +1 -0
- package/dist/core/tools/args.d.ts +49 -0
- package/dist/core/tools/args.js +46 -0
- package/dist/core/tools/index.d.ts +3 -0
- package/dist/core/tools/index.js +3 -0
- package/dist/entries/agent-tools.d.ts +1 -73
- package/dist/entries/agent-tools.js +19 -13
- package/dist/entries/errors.d.ts +28 -14
- package/dist/entries/errors.js +17 -11
- package/dist/entries/field-validation.d.ts +5 -3
- package/dist/entries/field-validation.js +7 -5
- package/dist/entries/index.d.ts +2 -2
- package/dist/entries/lifecycle-dispatch.d.ts +5 -2
- package/dist/entries/lifecycle-dispatch.js +2 -1
- package/dist/entries/list.d.ts +4 -2
- package/dist/entries/list.js +4 -3
- package/dist/entries/repo.memory.d.ts +12 -10
- package/dist/entries/repo.memory.js +11 -10
- package/dist/entries/tool-registrations.d.ts +6 -8
- package/dist/entries/tool-registrations.js +42 -41
- package/dist/entries/types.d.ts +0 -7
- package/dist/entries/write-service.d.ts +28 -29
- package/dist/entries/write-service.js +114 -105
- package/dist/http/settings/cms-adapter.d.ts +20 -0
- package/dist/http/settings/cms-adapter.js +124 -0
- package/dist/http/settings/contracts.d.ts +135 -0
- package/dist/http/settings/contracts.js +2 -0
- package/dist/http/settings/http.d.ts +10 -0
- package/dist/http/settings/http.js +211 -0
- package/dist/http/settings/index.d.ts +5 -0
- package/dist/http/settings/index.js +4 -0
- package/dist/http/settings/shared.d.ts +68 -0
- package/dist/http/settings/shared.js +54 -0
- package/dist/http/settings/stream.d.ts +24 -0
- package/dist/http/settings/stream.js +147 -0
- package/dist/media/agent-tools.d.ts +1 -66
- package/dist/media/agent-tools.js +5 -1
- package/dist/media/blob-gc-lock.d.ts +6 -2
- package/dist/media/blob-gc-lock.js +4 -2
- package/dist/media/blob-gc.d.ts +64 -51
- package/dist/media/blob-gc.js +62 -52
- package/dist/media/blob-key.d.ts +1 -1
- package/dist/media/content-type-sniffer.d.ts +3 -1
- package/dist/media/content-type-sniffer.js +1 -1
- package/dist/media/html-attributes.d.ts +9 -5
- package/dist/media/html-attributes.js +8 -6
- package/dist/media/image-transformer.js +1 -1
- package/dist/media/image-transformer.sharp.d.ts +6 -0
- package/dist/media/image-transformer.sharp.js +12 -6
- package/dist/media/import/fetch-image.d.ts +24 -0
- package/dist/media/import/fetch-image.js +137 -0
- package/dist/media/import/index.d.ts +3 -0
- package/dist/media/import/index.js +3 -0
- package/dist/media/import/ports.d.ts +38 -0
- package/dist/media/index.d.ts +7 -1
- package/dist/media/index.js +1 -0
- package/dist/media/media-service.d.ts +14 -10
- package/dist/media/media-service.js +57 -52
- package/dist/media/ports.d.ts +3 -2
- package/dist/media/rendition-service.d.ts +3 -3
- package/dist/media/rendition-service.js +38 -37
- package/dist/media/repo.memory.d.ts +18 -7
- package/dist/media/repo.memory.js +12 -6
- package/dist/media/tool-registrations.d.ts +16 -15
- package/dist/media/tool-registrations.js +84 -72
- package/dist/media/transform-lock.d.ts +22 -12
- package/dist/media/transform-lock.js +14 -13
- package/dist/media/transform-registry.d.ts +3 -3
- package/dist/media/transform-registry.js +19 -18
- package/dist/media/transform-types.d.ts +8 -3
- package/dist/media/transform-types.js +10 -5
- package/dist/media/types.d.ts +21 -4
- package/dist/media/types.js +15 -2
- package/dist/navigation/agent-tools.d.ts +1 -80
- package/dist/navigation/agent-tools.js +15 -3
- package/dist/navigation/contracts.d.ts +1 -1
- package/dist/navigation/index.d.ts +2 -2
- package/dist/navigation/menu-service.d.ts +57 -40
- package/dist/navigation/menu-service.js +80 -69
- package/dist/navigation/ports.d.ts +1 -1
- package/dist/navigation/read-model.d.ts +3 -1
- package/dist/navigation/read-model.js +2 -2
- package/dist/navigation/reconcile.d.ts +2 -2
- package/dist/navigation/reconcile.js +2 -1
- package/dist/navigation/repo.memory.d.ts +7 -3
- package/dist/navigation/repo.memory.js +4 -2
- package/dist/navigation/resolver.d.ts +4 -1
- package/dist/navigation/resolver.js +1 -1
- package/dist/navigation/tool-registrations.d.ts +6 -8
- package/dist/navigation/tool-registrations.js +44 -42
- package/dist/navigation/types.d.ts +1 -1
- package/dist/presentation/presentation.d.ts +11 -3
- package/dist/presentation/presentation.js +13 -6
- package/dist/presentation/repo.memory.d.ts +6 -2
- package/dist/presentation/repo.memory.js +3 -2
- package/dist/settings/agent-tools.d.ts +2 -75
- package/dist/settings/agent-tools.js +49 -45
- package/dist/settings/agent-value-write-tools.d.ts +22 -0
- package/dist/settings/agent-value-write-tools.js +154 -0
- package/dist/settings/agent-writable-preferences.js +8 -20
- package/dist/settings/agent-write-denylist.d.ts +19 -0
- package/dist/settings/agent-write-denylist.js +15 -0
- package/dist/settings/change-feed.d.ts +11 -2
- package/dist/settings/change-feed.js +4 -4
- package/dist/settings/definitions-dispatch.d.ts +8 -3
- package/dist/settings/definitions-dispatch.js +5 -5
- package/dist/settings/dictionaries/index.d.ts +5 -2
- package/dist/settings/dictionaries/index.js +2 -2
- package/dist/settings/dictionaries/settings-dialog.ar.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.bn.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.de.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.es.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.fa.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.fr.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.hi.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.hu.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.id.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.it.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ja.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ko.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.pl.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.pt-BR.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ru.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.th.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.tr.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.uk.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.ur.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-CN.js +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-TW.js +0 -1
- package/dist/settings/ensure-definitions.d.ts +5 -5
- package/dist/settings/errors.d.ts +7 -1
- package/dist/settings/errors.js +7 -1
- package/dist/settings/index.d.ts +4 -1
- package/dist/settings/index.js +1 -0
- package/dist/settings/ports.d.ts +6 -5
- package/dist/settings/principal-lookup.d.ts +9 -0
- package/dist/settings/principal-lookup.js +2 -0
- package/dist/settings/purge-service.d.ts +5 -4
- package/dist/settings/purge-service.js +3 -2
- package/dist/settings/settings.d.ts +22 -7
- package/dist/settings/settings.js +47 -26
- package/dist/settings/tool-registrations.d.ts +27 -32
- package/dist/settings/tool-registrations.js +66 -56
- package/dist/settings/types.d.ts +1 -1
- package/dist/settings/ui-tab-definitions.d.ts +1 -1
- package/dist/settings/write-service.d.ts +32 -26
- package/dist/settings/write-service.js +26 -20
- package/dist/taxonomy/agent-tools.d.ts +1 -66
- package/dist/taxonomy/agent-tools.js +25 -15
- package/dist/taxonomy/content-lookup.js +1 -1
- package/dist/taxonomy/index.d.ts +2 -2
- package/dist/taxonomy/merge-term.d.ts +7 -4
- package/dist/taxonomy/merge-term.js +7 -5
- package/dist/taxonomy/repo.memory.d.ts +42 -19
- package/dist/taxonomy/repo.memory.js +14 -12
- package/dist/taxonomy/validation-chain.d.ts +39 -18
- package/dist/taxonomy/validation-chain.js +35 -25
- package/dist/taxonomy/write-service.d.ts +103 -44
- package/dist/taxonomy/write-service.js +221 -179
- package/dist/trash/adapters/record-store.d.ts +52 -0
- package/dist/trash/adapters/record-store.js +36 -0
- package/dist/trash/cursor.d.ts +20 -0
- package/dist/trash/cursor.js +36 -0
- package/dist/trash/follow-ups.d.ts +76 -0
- package/dist/trash/follow-ups.js +46 -0
- package/dist/trash/index.d.ts +8 -0
- package/dist/trash/index.js +8 -0
- package/dist/trash/ports.d.ts +308 -0
- package/dist/trash/ports.js +2 -0
- package/dist/trash/repo.memory.d.ts +50 -0
- package/dist/trash/repo.memory.js +114 -0
- package/dist/trash/sweeper.d.ts +77 -0
- package/dist/trash/sweeper.js +127 -0
- package/dist/trash/write-service.d.ts +100 -0
- package/dist/trash/write-service.js +242 -0
- package/dist/workspace/agent-tools.d.ts +1 -59
- package/dist/workspace/agent-tools.js +27 -12
- package/dist/workspace/create.d.ts +31 -6
- package/dist/workspace/create.js +18 -5
- package/dist/workspace/delete.d.ts +18 -13
- package/dist/workspace/delete.js +20 -12
- package/dist/workspace/index.d.ts +1 -1
- package/dist/workspace/repo.memory.d.ts +19 -4
- package/dist/workspace/repo.memory.js +26 -4
- package/dist/workspace/tool-registrations.d.ts +2 -2
- package/dist/workspace/tool-registrations.js +13 -13
- package/dist/workspace/update.d.ts +6 -5
- package/dist/workspace/update.js +9 -8
- package/package.json +44 -38
- package/dist/core/tools/registration-kit.d.ts +0 -346
- package/dist/core/tools/registration-kit.js +0 -426
- package/dist/identity/admin-crud-service.d.ts +0 -280
- package/dist/identity/admin-crud-service.js +0 -468
- package/dist/identity/agent-tool-input.d.ts +0 -58
- package/dist/identity/agent-tool-input.js +0 -126
- package/dist/identity/agent-tools.d.ts +0 -101
- package/dist/identity/agent-tools.js +0 -290
- package/dist/identity/auth-service.d.ts +0 -118
- package/dist/identity/auth-service.js +0 -170
- package/dist/identity/authorize.d.ts +0 -90
- package/dist/identity/authorize.js +0 -94
- package/dist/identity/grant-service.d.ts +0 -198
- package/dist/identity/grant-service.js +0 -361
- package/dist/identity/hasher.d.ts +0 -39
- package/dist/identity/hasher.js +0 -39
- package/dist/identity/index.d.ts +0 -62
- package/dist/identity/index.js +0 -54
- package/dist/identity/password-policy.d.ts +0 -57
- package/dist/identity/password-policy.js +0 -66
- package/dist/identity/permission-migrations.d.ts +0 -86
- package/dist/identity/permission-migrations.js +0 -67
- package/dist/identity/permissions.d.ts +0 -35
- package/dist/identity/permissions.js +0 -603
- package/dist/identity/ports.d.ts +0 -201
- package/dist/identity/repo.memory.d.ts +0 -164
- package/dist/identity/repo.memory.js +0 -224
- package/dist/identity/seed.d.ts +0 -54
- package/dist/identity/seed.js +0 -429
- package/dist/identity/tool-registrations.d.ts +0 -50
- package/dist/identity/tool-registrations.js +0 -419
- package/dist/identity/types.d.ts +0 -185
- package/dist/identity/types.js +0 -64
- package/dist/identity/username.d.ts +0 -12
- package/dist/identity/username.js +0 -14
- package/dist/server/index.d.ts +0 -28
- package/dist/server/index.js +0 -28
- /package/dist/{identity → media/import}/ports.js +0 -0
|
@@ -1,34 +1,36 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { type
|
|
1
|
+
import type { Clock } from "@jini-ai/core/primitives";
|
|
2
|
+
import { type Result } from "@jini-ai/core/primitives";
|
|
3
|
+
import { type ActorIdentityInput, type ActorPrincipalKind, type ContentTypeFieldDef, type ContentTypeRecord } from "./types.js";
|
|
3
4
|
/**
|
|
4
5
|
* @file Write-service — `registerContentType`/`updateContentTypeFields`, the single write
|
|
5
6
|
* chokepoint for the `content_types` registry.
|
|
6
7
|
*
|
|
7
8
|
* Purpose:
|
|
8
9
|
* The ONLY path that creates or full-replaces a content type's field schema. Every export here:
|
|
9
|
-
* `authorize
|
|
10
|
+
* `authorize` first (fail-closed) -> the fixed definition-time guard sequence ->
|
|
10
11
|
* same-transaction row + revision write (+ watermark stamp when supplied) -> index provisioning.
|
|
11
12
|
*
|
|
12
|
-
* `registerContentType`'s
|
|
13
|
+
* `registerContentType`'s guard order is binding, not incidental: key grammar ->
|
|
13
14
|
* reserved-key -> field-name grammar -> field-kind -> storage-only-not-queryable -> queryable-cap,
|
|
14
15
|
* evaluation stops at the first failure. The storage-only check is numbered 4b rather than
|
|
15
16
|
* renumbering 5, because the fixed order is pinned by name in the certified suite and the existing
|
|
16
17
|
* five guards keep both their relative order and their identity: 4b sits strictly between the
|
|
17
18
|
* field-kind check and the queryable-cap check, and can only fire on a field whose kind already
|
|
18
|
-
* passed guard 4. `updateContentTypeFields`
|
|
19
|
+
* passed guard 4. `updateContentTypeFields` additionally requires `expectedVersion`
|
|
19
20
|
* to be checked BEFORE the `fields_empty` floor and before any per-field guard, so a stale
|
|
20
21
|
* `expectedVersion` combined with `fields: []` reports `VERSION_CONFLICT`, never
|
|
21
22
|
* `VALIDATION_ERROR(fields_empty)`.
|
|
22
23
|
*
|
|
23
24
|
* How it relates to the project:
|
|
24
|
-
* `deps.watermark`
|
|
25
|
-
* (`delegatedByWorkspaceId`/`delegatedById
|
|
25
|
+
* `deps.watermark` and the actor-identity delegation fields
|
|
26
|
+
* (`delegatedByWorkspaceId`/`delegatedById`) are both optional/additive — every
|
|
26
27
|
* pre-existing call site that omits them is unaffected; `watermark-stamping.integration.test.ts`
|
|
27
28
|
* exercises both.
|
|
28
29
|
*
|
|
29
30
|
* Architectural role:
|
|
30
31
|
* `features/content-types` domain logic. Depends only on `core/ports` and this package's own
|
|
31
32
|
* `errors.ts`/`index-provisioning.ts`/`types.ts` — no adapter, no other feature.
|
|
33
|
+
* See docs/decisions/DR-001-safe-schema-and-index-transitions.md.
|
|
32
34
|
*/
|
|
33
35
|
/**
|
|
34
36
|
* Matches every other feature's chokepoint `AuthorizeFn` shape structurally — no shared import,
|
|
@@ -40,16 +42,8 @@ import { type ActorIdentityInput, type ActorPrincipalKind, type ContentTypeField
|
|
|
40
42
|
* `resource_scope_mismatch` even though the route's pre-check allowed it, and why this chokepoint —
|
|
41
43
|
* not the route — is the one that must stay at least as expressive.
|
|
42
44
|
*/
|
|
43
|
-
export type AuthorizeFn
|
|
44
|
-
|
|
45
|
-
permission: string;
|
|
46
|
-
workspaceId: string;
|
|
47
|
-
entityType?: string | undefined;
|
|
48
|
-
entityId?: string | undefined;
|
|
49
|
-
}) => Promise<{
|
|
50
|
-
allowed: boolean;
|
|
51
|
-
reason: string;
|
|
52
|
-
}>;
|
|
45
|
+
export type { AuthorizationPort as AuthorizeFn } from '../core/authorization.js';
|
|
46
|
+
import type { AuthorizationPort as AuthorizeFn } from '../core/authorization.js';
|
|
53
47
|
export interface ContentTypeRevisionInput {
|
|
54
48
|
contentTypeKey: string;
|
|
55
49
|
workspaceId: string;
|
|
@@ -74,7 +68,9 @@ export interface ContentTypeRepoPort {
|
|
|
74
68
|
workspaceId: string;
|
|
75
69
|
key: string;
|
|
76
70
|
}): Promise<ContentTypeRecord | null>;
|
|
77
|
-
transaction<T>(
|
|
71
|
+
transaction<T>(required: {
|
|
72
|
+
fn: () => Promise<T>;
|
|
73
|
+
}): Promise<T>;
|
|
78
74
|
}
|
|
79
75
|
export interface IndexProvisionerPort {
|
|
80
76
|
provisionIndexesForNewContentType(input: {
|
|
@@ -97,7 +93,7 @@ export interface OutboxPort {
|
|
|
97
93
|
payload: Record<string, unknown>;
|
|
98
94
|
}): Promise<void>;
|
|
99
95
|
}
|
|
100
|
-
/** Optional
|
|
96
|
+
/** Optional — advances `database_write_watermark` by exactly 1 when supplied. See docs/decisions/DR-001-safe-schema-and-index-transitions.md. */
|
|
101
97
|
export interface WatermarkPort {
|
|
102
98
|
stampWatermark(input: {
|
|
103
99
|
workspaceId: string;
|
|
@@ -105,7 +101,7 @@ export interface WatermarkPort {
|
|
|
105
101
|
}
|
|
106
102
|
export interface ContentTypeWriteServiceDeps {
|
|
107
103
|
repo: ContentTypeRepoPort;
|
|
108
|
-
clock:
|
|
104
|
+
clock: Clock;
|
|
109
105
|
ids: {
|
|
110
106
|
newId: () => string;
|
|
111
107
|
};
|
|
@@ -124,11 +120,12 @@ export interface RegisterContentTypeRequired {
|
|
|
124
120
|
};
|
|
125
121
|
}
|
|
126
122
|
/**
|
|
127
|
-
*
|
|
123
|
+
* 08 — registers a new content type. Guard order is, fixed and stop-at-first-
|
|
128
124
|
* failure: key grammar -> reserved-key -> field-name grammar -> field-kind -> queryable-cap.
|
|
129
125
|
*
|
|
130
126
|
* @complexity O(f) in the number of submitted fields (two guard passes over `fields`).
|
|
131
127
|
* @overallScore 100
|
|
128
|
+
* See docs/decisions/DR-001-safe-schema-and-index-transitions.md.
|
|
132
129
|
*/
|
|
133
130
|
export declare function registerContentType(required: RegisterContentTypeRequired): Promise<Result<{
|
|
134
131
|
contentType: ContentTypeRecord;
|
|
@@ -152,12 +149,13 @@ export interface UpdateContentTypeFieldsRequired {
|
|
|
152
149
|
};
|
|
153
150
|
}
|
|
154
151
|
/**
|
|
155
|
-
*
|
|
152
|
+
* — full-replace of a content type's field schema. : `expectedVersion` is
|
|
156
153
|
* checked BEFORE the `fields_empty` floor and before any per-field guard, so a stale version
|
|
157
154
|
* combined with an empty array reports `VERSION_CONFLICT`, never `VALIDATION_ERROR`.
|
|
158
155
|
*
|
|
159
156
|
* @complexity O(f) in the number of submitted fields.
|
|
160
157
|
* @overallScore 100
|
|
158
|
+
* See docs/decisions/DR-001-safe-schema-and-index-transitions.md.
|
|
161
159
|
*/
|
|
162
160
|
export declare function updateContentTypeFields(required: UpdateContentTypeFieldsRequired): Promise<Result<{
|
|
163
161
|
contentType: ContentTypeRecord;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
|
|
1
2
|
import { EntityNotLiveError } from "../core/entity-liveness.js";
|
|
2
3
|
import { ContentTypeAlreadyExistsError, ContentTypeNotFoundError, ForbiddenError, InvalidFieldKindError, InvalidFieldNameGrammarError, InvalidKeyGrammarError, QueryableFieldCapExceededError, ReservedContentTypeKeyError, StorageOnlyFieldNotQueryableError, ValidationError, VersionConflictError, } from "./errors.js";
|
|
3
4
|
import { resolveFieldIndexTransition, validateIdentifierGrammar } from "./index-provisioning.js";
|
|
4
|
-
import {
|
|
5
|
+
import {} from "@jini-ai/core/primitives";
|
|
6
|
+
import { isContentTypeFieldKind, isIndexableFieldKind } from "./types.js";
|
|
5
7
|
/** Reserved forever for the legacy `posts` table — an operator Collection can never take these keys. */
|
|
6
8
|
/**
|
|
7
9
|
* Field-name guards shared by register and update, in order: every name passes the identifier
|
|
@@ -14,14 +16,14 @@ import { isContentTypeFieldKind, isIndexableFieldKind, } from "./types.js";
|
|
|
14
16
|
*/
|
|
15
17
|
function fieldNameError(fields) {
|
|
16
18
|
for (const field of fields) {
|
|
17
|
-
if (!validateIdentifierGrammar(field.name)) {
|
|
18
|
-
return new InvalidFieldNameGrammarError(`field name '${field.name}' fails the identifier grammar gate`);
|
|
19
|
+
if (!validateIdentifierGrammar({ value: field.name })) {
|
|
20
|
+
return new InvalidFieldNameGrammarError({ message: `field name '${field.name}' fails the identifier grammar gate` });
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
const seen = new Set();
|
|
22
24
|
for (const field of fields) {
|
|
23
25
|
if (seen.has(field.name)) {
|
|
24
|
-
return new ValidationError(`field name '${field.name}' appears more than once`, "fields_duplicate_name");
|
|
26
|
+
return new ValidationError({ message: `field name '${field.name}' appears more than once`, reason: "fields_duplicate_name" });
|
|
25
27
|
}
|
|
26
28
|
seen.add(field.name);
|
|
27
29
|
}
|
|
@@ -34,30 +36,30 @@ function countQueryableFields(fields) {
|
|
|
34
36
|
return fields.filter((f) => f.queryable).length;
|
|
35
37
|
}
|
|
36
38
|
/**
|
|
37
|
-
*
|
|
39
|
+
* 08 — registers a new content type. Guard order is, fixed and stop-at-first-
|
|
38
40
|
* failure: key grammar -> reserved-key -> field-name grammar -> field-kind -> queryable-cap.
|
|
39
41
|
*
|
|
40
42
|
* @complexity O(f) in the number of submitted fields (two guard passes over `fields`).
|
|
41
43
|
* @overallScore 100
|
|
44
|
+
* See docs/decisions/DR-001-safe-schema-and-index-transitions.md.
|
|
42
45
|
*/
|
|
43
46
|
export async function registerContentType(required) {
|
|
44
47
|
const { deps, input } = required;
|
|
45
48
|
const authResult = await deps.authorize({
|
|
46
49
|
principalId: input.actorId,
|
|
47
50
|
permission: "admin.collections.manage",
|
|
48
|
-
workspaceId: input.workspaceId
|
|
49
|
-
|
|
50
|
-
});
|
|
51
|
+
workspaceId: input.workspaceId
|
|
52
|
+
}, { entityType: "content-type" });
|
|
51
53
|
if (!authResult.allowed) {
|
|
52
|
-
return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot register a content type (${authResult.reason})`) };
|
|
54
|
+
return { ok: false, error: new ForbiddenError({ message: `principal '${input.actorId}' cannot register a content type (${authResult.reason})` }) };
|
|
53
55
|
}
|
|
54
56
|
// Guard 1: key grammar.
|
|
55
|
-
if (!validateIdentifierGrammar(input.key)) {
|
|
56
|
-
return { ok: false, error: new InvalidKeyGrammarError(`content-type key '${input.key}' fails the identifier grammar gate ^[a-z][a-z0-9_]{0,63}$`) };
|
|
57
|
+
if (!validateIdentifierGrammar({ value: input.key })) {
|
|
58
|
+
return { ok: false, error: new InvalidKeyGrammarError({ message: `content-type key '${input.key}' fails the identifier grammar gate ^[a-z][a-z0-9_]{0,63}$` }) };
|
|
57
59
|
}
|
|
58
60
|
// Guard 2: reserved key.
|
|
59
61
|
if (RESERVED_CONTENT_TYPE_KEYS.has(input.key)) {
|
|
60
|
-
return { ok: false, error: new ReservedContentTypeKeyError(`key '${input.key}' is permanently reserved for the legacy 'posts' table`) };
|
|
62
|
+
return { ok: false, error: new ReservedContentTypeKeyError({ message: `key '${input.key}' is permanently reserved for the legacy 'posts' table` }) };
|
|
61
63
|
}
|
|
62
64
|
// Guard 3: field-name grammar, then uniqueness (every field, before any kind/cap check).
|
|
63
65
|
const nameErrorOnRegister = fieldNameError(input.fields);
|
|
@@ -65,8 +67,8 @@ export async function registerContentType(required) {
|
|
|
65
67
|
return { ok: false, error: nameErrorOnRegister };
|
|
66
68
|
// Guard 4: field-kind, closed enum.
|
|
67
69
|
for (const field of input.fields) {
|
|
68
|
-
if (!isContentTypeFieldKind(field.kind)) {
|
|
69
|
-
return { ok: false, error: new InvalidFieldKindError(`field '${field.name}' has kind '${field.kind}', not one of the closed field-kind enum`) };
|
|
70
|
+
if (!isContentTypeFieldKind({ value: field.kind })) {
|
|
71
|
+
return { ok: false, error: new InvalidFieldKindError({ message: `field '${field.name}' has kind '${field.kind}', not one of the closed field-kind enum` }) };
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
// Guard 4b: a storage-only kind cannot be queryable. Placed strictly between guard 4 and guard 5
|
|
@@ -74,24 +76,24 @@ export async function registerContentType(required) {
|
|
|
74
76
|
// BEFORE the cap count and long before `resolveFieldIndexTransition` could hand it to the index
|
|
75
77
|
// provisioner. This is the check that keeps a kind with no CAST target off the DDL path.
|
|
76
78
|
for (const field of input.fields) {
|
|
77
|
-
if (field.queryable && !isIndexableFieldKind(field.kind)) {
|
|
79
|
+
if (field.queryable && !isIndexableFieldKind({ value: field.kind })) {
|
|
78
80
|
return {
|
|
79
81
|
ok: false,
|
|
80
|
-
error: new StorageOnlyFieldNotQueryableError(`field '${field.name}' has storage-only kind '${field.kind}' and cannot be queryable`),
|
|
82
|
+
error: new StorageOnlyFieldNotQueryableError({ message: `field '${field.name}' has storage-only kind '${field.kind}' and cannot be queryable` }),
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
// Guard 5: queryable-field cap, submitted array alone.
|
|
85
87
|
if (countQueryableFields(input.fields) > QUERYABLE_FIELD_CAP) {
|
|
86
|
-
return { ok: false, error: new QueryableFieldCapExceededError(`content type '${input.key}' submits more than ${QUERYABLE_FIELD_CAP} queryable fields`) };
|
|
88
|
+
return { ok: false, error: new QueryableFieldCapExceededError({ message: `content type '${input.key}' submits more than ${QUERYABLE_FIELD_CAP} queryable fields` }) };
|
|
87
89
|
}
|
|
88
90
|
// Row 7 — a pre-check outside the transaction. Not race-safe by itself (that's the in-tx check
|
|
89
91
|
// below), but it avoids opening a transaction for the overwhelmingly common not-found case.
|
|
90
92
|
const existingBeforeTx = await deps.repo.findByKey({ workspaceId: input.workspaceId, key: input.key });
|
|
91
93
|
if (existingBeforeTx) {
|
|
92
|
-
return { ok: false, error: new ContentTypeAlreadyExistsError(input.key, existingBeforeTx.status === "tombstone") };
|
|
94
|
+
return { ok: false, error: new ContentTypeAlreadyExistsError({ key: input.key, tombstoned: existingBeforeTx.status === "tombstone" }) };
|
|
93
95
|
}
|
|
94
|
-
const now = deps.clock
|
|
96
|
+
const now = kernelNowIso({ clock: deps.clock });
|
|
95
97
|
const contentType = {
|
|
96
98
|
workspaceId: input.workspaceId,
|
|
97
99
|
key: input.key,
|
|
@@ -102,28 +104,28 @@ export async function registerContentType(required) {
|
|
|
102
104
|
tombstonedAt: null,
|
|
103
105
|
};
|
|
104
106
|
try {
|
|
105
|
-
await deps.repo.transaction(async () => {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
107
|
+
await deps.repo.transaction({ fn: async () => {
|
|
108
|
+
// The check that actually closes the race: two concurrent first-widget creates both see the
|
|
109
|
+
// pre-check above return null, but only one of them can win this in-tx re-check before save.
|
|
110
|
+
const existingInTx = await deps.repo.findByKey({ workspaceId: input.workspaceId, key: input.key });
|
|
111
|
+
if (existingInTx) {
|
|
112
|
+
throw new ContentTypeAlreadyExistsError({ key: input.key, tombstoned: existingInTx.status === "tombstone" });
|
|
113
|
+
}
|
|
114
|
+
await deps.repo.save(contentType);
|
|
115
|
+
await deps.repo.appendRevision({
|
|
116
|
+
contentTypeKey: input.key,
|
|
117
|
+
workspaceId: input.workspaceId,
|
|
118
|
+
op: "register",
|
|
119
|
+
stateJson: contentType,
|
|
120
|
+
actorId: input.actorId,
|
|
121
|
+
principalKind: input.principalKind ?? null,
|
|
122
|
+
delegatedByWorkspaceId: input.delegatedByWorkspaceId ?? null,
|
|
123
|
+
delegatedById: input.delegatedById ?? null,
|
|
124
|
+
recordedAt: now,
|
|
125
|
+
});
|
|
126
|
+
if (deps.watermark)
|
|
127
|
+
await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
|
|
128
|
+
} });
|
|
127
129
|
}
|
|
128
130
|
catch (err) {
|
|
129
131
|
if (err instanceof ContentTypeAlreadyExistsError)
|
|
@@ -140,47 +142,47 @@ export async function registerContentType(required) {
|
|
|
140
142
|
return { ok: true, value: { contentType } };
|
|
141
143
|
}
|
|
142
144
|
/**
|
|
143
|
-
*
|
|
145
|
+
* — full-replace of a content type's field schema. : `expectedVersion` is
|
|
144
146
|
* checked BEFORE the `fields_empty` floor and before any per-field guard, so a stale version
|
|
145
147
|
* combined with an empty array reports `VERSION_CONFLICT`, never `VALIDATION_ERROR`.
|
|
146
148
|
*
|
|
147
149
|
* @complexity O(f) in the number of submitted fields.
|
|
148
150
|
* @overallScore 100
|
|
151
|
+
* See docs/decisions/DR-001-safe-schema-and-index-transitions.md.
|
|
149
152
|
*/
|
|
150
153
|
export async function updateContentTypeFields(required) {
|
|
151
154
|
const { deps, input } = required;
|
|
152
155
|
const authResult = await deps.authorize({
|
|
153
156
|
principalId: input.actorId,
|
|
154
157
|
permission: "admin.collections.manage",
|
|
155
|
-
workspaceId: input.workspaceId
|
|
156
|
-
|
|
157
|
-
});
|
|
158
|
+
workspaceId: input.workspaceId
|
|
159
|
+
}, { entityType: "content-type" });
|
|
158
160
|
if (!authResult.allowed) {
|
|
159
|
-
return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot update content type '${input.key}' (${authResult.reason})`) };
|
|
161
|
+
return { ok: false, error: new ForbiddenError({ message: `principal '${input.actorId}' cannot update content type '${input.key}' (${authResult.reason})` }) };
|
|
160
162
|
}
|
|
161
163
|
const current = await deps.repo.findByKey({ workspaceId: input.workspaceId, key: input.key });
|
|
162
164
|
if (!current) {
|
|
163
|
-
return { ok: false, error: new ContentTypeNotFoundError(`content type '${input.key}' was not found in workspace '${input.workspaceId}'`) };
|
|
165
|
+
return { ok: false, error: new ContentTypeNotFoundError({ message: `content type '${input.key}' was not found in workspace '${input.workspaceId}'` }) };
|
|
164
166
|
}
|
|
165
|
-
// Row 15's sibling — a tombstoned type is terminal
|
|
167
|
+
// Row 15's sibling — a tombstoned type is terminal; this check runs before the version. See docs/decisions/DR-001-safe-schema-and-index-transitions.md.
|
|
166
168
|
// check so a stale-version update against a tombstoned row still reports "tombstoned", not
|
|
167
169
|
// "version conflict" (the tombstone tore down every queryable index, so the schema is moot).
|
|
168
170
|
if (current.status === "tombstone") {
|
|
169
|
-
return { ok: false, error: new EntityNotLiveError("content type", input.key, "tombstoned") };
|
|
171
|
+
return { ok: false, error: new EntityNotLiveError({ entityType: "content type", entityId: input.key, state: "tombstoned" }) };
|
|
170
172
|
}
|
|
171
|
-
//
|
|
173
|
+
// expectedVersion checked FIRST — before fields_empty, before any per-field guard. See docs/decisions/DR-001-safe-schema-and-index-transitions.md.
|
|
172
174
|
if (input.expectedVersion !== current.version) {
|
|
173
|
-
return { ok: false, error: new VersionConflictError(`expected version ${input.expectedVersion} for content type '${input.key}', found ${current.version}`) };
|
|
175
|
+
return { ok: false, error: new VersionConflictError({ message: `expected version ${input.expectedVersion} for content type '${input.key}', found ${current.version}` }) };
|
|
174
176
|
}
|
|
175
177
|
if (input.fields.length === 0) {
|
|
176
|
-
return { ok: false, error: new ValidationError(`content type '${input.key}' update submitted an empty fields array`, "fields_empty") };
|
|
178
|
+
return { ok: false, error: new ValidationError({ message: `content type '${input.key}' update submitted an empty fields array`, reason: "fields_empty" }) };
|
|
177
179
|
}
|
|
178
180
|
const nameErrorOnUpdate = fieldNameError(input.fields);
|
|
179
181
|
if (nameErrorOnUpdate)
|
|
180
182
|
return { ok: false, error: nameErrorOnUpdate };
|
|
181
183
|
for (const field of input.fields) {
|
|
182
|
-
if (!isContentTypeFieldKind(field.kind)) {
|
|
183
|
-
return { ok: false, error: new InvalidFieldKindError(`field '${field.name}' has kind '${field.kind}', not one of the closed field-kind enum`) };
|
|
184
|
+
if (!isContentTypeFieldKind({ value: field.kind })) {
|
|
185
|
+
return { ok: false, error: new InvalidFieldKindError({ message: `field '${field.name}' has kind '${field.kind}', not one of the closed field-kind enum` }) };
|
|
184
186
|
}
|
|
185
187
|
}
|
|
186
188
|
// Guard 4b: a storage-only kind cannot be queryable. Placed strictly between guard 4 and guard 5
|
|
@@ -188,36 +190,36 @@ export async function updateContentTypeFields(required) {
|
|
|
188
190
|
// BEFORE the cap count and long before `resolveFieldIndexTransition` could hand it to the index
|
|
189
191
|
// provisioner. This is the check that keeps a kind with no CAST target off the DDL path.
|
|
190
192
|
for (const field of input.fields) {
|
|
191
|
-
if (field.queryable && !isIndexableFieldKind(field.kind)) {
|
|
193
|
+
if (field.queryable && !isIndexableFieldKind({ value: field.kind })) {
|
|
192
194
|
return {
|
|
193
195
|
ok: false,
|
|
194
|
-
error: new StorageOnlyFieldNotQueryableError(`field '${field.name}' has storage-only kind '${field.kind}' and cannot be queryable`),
|
|
196
|
+
error: new StorageOnlyFieldNotQueryableError({ message: `field '${field.name}' has storage-only kind '${field.kind}' and cannot be queryable` }),
|
|
195
197
|
};
|
|
196
198
|
}
|
|
197
199
|
}
|
|
198
200
|
if (countQueryableFields(input.fields) > QUERYABLE_FIELD_CAP) {
|
|
199
|
-
return { ok: false, error: new QueryableFieldCapExceededError(`content type '${input.key}' submits more than ${QUERYABLE_FIELD_CAP} queryable fields`) };
|
|
201
|
+
return { ok: false, error: new QueryableFieldCapExceededError({ message: `content type '${input.key}' submits more than ${QUERYABLE_FIELD_CAP} queryable fields` }) };
|
|
200
202
|
}
|
|
201
203
|
const before = current.fields;
|
|
202
204
|
const after = input.fields;
|
|
203
|
-
const now = deps.clock
|
|
205
|
+
const now = kernelNowIso({ clock: deps.clock });
|
|
204
206
|
const updated = { ...current, fields: after, label: input.label ?? current.label, version: current.version + 1 };
|
|
205
|
-
await deps.repo.transaction(async () => {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
207
|
+
await deps.repo.transaction({ fn: async () => {
|
|
208
|
+
await deps.repo.save(updated);
|
|
209
|
+
await deps.repo.appendRevision({
|
|
210
|
+
contentTypeKey: input.key,
|
|
211
|
+
workspaceId: input.workspaceId,
|
|
212
|
+
op: "field-change",
|
|
213
|
+
stateJson: updated,
|
|
214
|
+
actorId: input.actorId,
|
|
215
|
+
principalKind: input.principalKind ?? null,
|
|
216
|
+
delegatedByWorkspaceId: input.delegatedByWorkspaceId ?? null,
|
|
217
|
+
delegatedById: input.delegatedById ?? null,
|
|
218
|
+
recordedAt: now,
|
|
219
|
+
});
|
|
220
|
+
if (deps.watermark)
|
|
221
|
+
await deps.watermark.stampWatermark({ workspaceId: input.workspaceId });
|
|
222
|
+
} });
|
|
221
223
|
await deps.indexProvisioner.applyFieldIndexTransitions({
|
|
222
224
|
workspaceId: input.workspaceId,
|
|
223
225
|
contentTypeKey: input.key,
|
|
@@ -226,12 +228,13 @@ export async function updateContentTypeFields(required) {
|
|
|
226
228
|
return { ok: true, value: { contentType: updated } };
|
|
227
229
|
}
|
|
228
230
|
/**
|
|
229
|
-
*
|
|
231
|
+
* composition — resolves an index transition for every field name present on either
|
|
230
232
|
* side of a full-replace update (union of before/after names), one before/after comparison per
|
|
231
233
|
* name, and returns only the actionable (non-`"none"`) transitions.
|
|
232
234
|
*
|
|
233
235
|
* @complexity O(f) in the number of distinct field names across both arrays.
|
|
234
236
|
* @overallScore 100
|
|
237
|
+
* See docs/decisions/DR-001-safe-schema-and-index-transitions.md.
|
|
235
238
|
*/
|
|
236
239
|
function computeFullReplaceTransitions(before, after) {
|
|
237
240
|
const beforeByName = new Map(before.map((f) => [f.name, f]));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Caller-supplied authorization evaluator shared by content domain write services. */
|
|
2
|
+
export interface AuthorizationRequired {
|
|
3
|
+
principalId: string;
|
|
4
|
+
permission: string;
|
|
5
|
+
workspaceId: string;
|
|
6
|
+
}
|
|
7
|
+
export interface AuthorizationOptional {
|
|
8
|
+
entityType?: string | undefined;
|
|
9
|
+
entityId?: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
export type AuthorizationPort = (required: AuthorizationRequired, optional?: AuthorizationOptional) => Promise<{
|
|
12
|
+
allowed: boolean;
|
|
13
|
+
reason: string;
|
|
14
|
+
}>;
|
|
15
|
+
//# sourceMappingURL=authorization.d.ts.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ISODateTime, JsonObject, UUID } from "@jini-ai/core/primitives";
|
|
2
|
+
import type { DomainEvent } from "../ports.js";
|
|
2
3
|
/**
|
|
3
4
|
* @file Change-set vocabulary and persistence contract.
|
|
4
5
|
*
|
|
@@ -15,7 +16,7 @@ import type { DomainEvent, ISODateTime, JsonObject, UUID } from "../ports.js";
|
|
|
15
16
|
* This is the storage shape required to land with the first persistent
|
|
16
17
|
* schema. SQLite/Postgres adapters must satisfy `ChangeSetRepoPort` unchanged.
|
|
17
18
|
*
|
|
18
|
-
* `insert()`'s optional
|
|
19
|
+
* `insert()`'s optional event in its second object lets the caller pass the fully-formed outbox event through
|
|
19
20
|
* the SAME call, so a durable adapter can co-persist it inside the same transaction as the header
|
|
20
21
|
* + items. This does NOT bring the domain write (`mutation.execute()`) into the transaction — that
|
|
21
22
|
* remains covered by `executeCommand()`'s existing compensating-rollback path. See
|
|
@@ -59,10 +60,11 @@ export interface ChangeSetItemRecord {
|
|
|
59
60
|
*/
|
|
60
61
|
inversePayload?: JsonObject | undefined;
|
|
61
62
|
/**
|
|
62
|
-
* Entity version *after* the mutation (
|
|
63
|
+
* Entity version *after* the mutation ( guard input). Revert refuses
|
|
63
64
|
* unless the entity's current version still equals this — otherwise the entity
|
|
64
65
|
* has moved on since. Undefined for entity types without a version.
|
|
65
|
-
|
|
66
|
+
* See docs/decisions/DR-006-mutation-audit-atomicity.md.
|
|
67
|
+
*/
|
|
66
68
|
entityVersionAtApply?: number | undefined;
|
|
67
69
|
/** Apply order; revert walks positions in reverse. */
|
|
68
70
|
position: number;
|
|
@@ -75,12 +77,18 @@ export interface ChangeSetWithItems {
|
|
|
75
77
|
/** Persistence contract for change sets. */
|
|
76
78
|
export interface ChangeSetRepoPort {
|
|
77
79
|
/**
|
|
78
|
-
* `event`, when present, must be durably recorded atomically with `record`/`items` (
|
|
80
|
+
* `event`, when present, must be durably recorded atomically with `record`/`items` ( —
|
|
79
81
|
* see this file's header). A SQLite adapter co-persists it inside the same transaction; the
|
|
80
82
|
* in-memory adapter forwards it to its injected event bus/outbox. Omit `event` for a change set
|
|
81
83
|
* that has no associated domain event to deliver.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
* See docs/decisions/DR-006-mutation-audit-atomicity.md.
|
|
85
|
+
*/
|
|
86
|
+
insert(required: {
|
|
87
|
+
record: ChangeSetRecord;
|
|
88
|
+
items: ChangeSetItemRecord[];
|
|
89
|
+
}, optional?: {
|
|
90
|
+
event?: DomainEvent | undefined;
|
|
91
|
+
}): Promise<void>;
|
|
84
92
|
findById(required: {
|
|
85
93
|
workspaceId: UUID;
|
|
86
94
|
id: UUID;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Clock, IdGenerator, JsonObject, UUID } from "@jini-ai/core/primitives";
|
|
2
|
+
import type { OutboxPort } from "../ports.js";
|
|
2
3
|
import type { ChangeSetOperation, ChangeSetRepoPort } from "./change-set.js";
|
|
3
4
|
/**
|
|
4
5
|
* @file The single mutation write path.
|
|
@@ -58,20 +59,27 @@ export interface CommandMutation<TResult> {
|
|
|
58
59
|
execute(): Promise<TResult>;
|
|
59
60
|
/**
|
|
60
61
|
* Read the entity's version from the execute result (the version *after* the
|
|
61
|
-
* mutation
|
|
62
|
+
* mutation). Stamped onto the item as the revert guard input.
|
|
62
63
|
* Omit for entity types without a version.
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
* See docs/decisions/DR-006-mutation-audit-atomicity.md.
|
|
65
|
+
*/
|
|
66
|
+
captureEntityVersion?(required: {
|
|
67
|
+
result: TResult;
|
|
68
|
+
}, optional?: Record<string, never>): number | null;
|
|
65
69
|
/**
|
|
66
70
|
* Compensating rollback for the memory adapter's all-or-nothing guarantee
|
|
67
|
-
*
|
|
68
|
-
* *after* `execute
|
|
71
|
+
* If version capture, record preparation or persistence fails
|
|
72
|
+
* *after* `execute` has applied the feature mutation, the gateway calls this
|
|
69
73
|
* to undo the mutation — restoring the entity to its exact pre-`execute` state
|
|
70
74
|
* (verbatim, including `version`) so no "mutation without a record" survives
|
|
71
|
-
*
|
|
72
|
-
* `rollback`
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
+
* On the SQLite adapter a real transaction replaces this and
|
|
76
|
+
* `rollback` may be a no-op/omitted only when a caller-owned transaction covers
|
|
77
|
+
* the feature write AND all subsequent record preparation/persistence. The gateway
|
|
78
|
+
* opens no such transaction itself. Without rollback or that outer transaction, a
|
|
79
|
+
* post-mutation failure rejects but leaves the feature mutation applied (
|
|
80
|
+
* is not guaranteed). A rollback rejection takes precedence over the original error.
|
|
81
|
+
* See docs/decisions/DR-006-mutation-audit-atomicity.md.
|
|
82
|
+
*/
|
|
75
83
|
rollback?(): Promise<void>;
|
|
76
84
|
}
|
|
77
85
|
/**
|
|
@@ -95,8 +103,8 @@ export type AuthorizeFn = (params: {
|
|
|
95
103
|
}>;
|
|
96
104
|
/** Dependencies for executeCommand. */
|
|
97
105
|
export interface ExecuteCommandDeps {
|
|
98
|
-
clock:
|
|
99
|
-
idGen:
|
|
106
|
+
clock: Clock;
|
|
107
|
+
idGen: IdGenerator;
|
|
100
108
|
changeSets: ChangeSetRepoPort;
|
|
101
109
|
/** When provided, `change-set.applied` is enqueued for async consumers. */
|
|
102
110
|
outbox?: OutboxPort | undefined;
|
|
@@ -126,18 +134,26 @@ export interface ExecuteCommandOptional {
|
|
|
126
134
|
*/
|
|
127
135
|
export declare class DuplicateCommandError extends Error {
|
|
128
136
|
readonly changeSetId: UUID;
|
|
129
|
-
constructor(
|
|
137
|
+
constructor(requiredArgs: {
|
|
138
|
+
message: string;
|
|
139
|
+
changeSetId: UUID;
|
|
140
|
+
}, optionalArgs?: Record<string, never>);
|
|
130
141
|
}
|
|
131
142
|
/**
|
|
132
|
-
* Thrown when `authorize
|
|
133
|
-
* this to 403 `FORBIDDEN`. Raised before the idempotency check
|
|
143
|
+
* Thrown when `authorize` denies the caller. Routes map
|
|
144
|
+
* this to 403 `FORBIDDEN`. Raised before the idempotency check — a
|
|
134
145
|
* replayed command id from a denied caller therefore never produces
|
|
135
|
-
* `DuplicateCommandError` either
|
|
146
|
+
* `DuplicateCommandError` either.
|
|
147
|
+
* See docs/decisions/DR-006-mutation-audit-atomicity.md.
|
|
136
148
|
*/
|
|
137
149
|
export declare class ForbiddenError extends Error {
|
|
138
150
|
readonly permission: string;
|
|
139
151
|
readonly reason: string;
|
|
140
|
-
constructor(message
|
|
152
|
+
constructor({ message, permission, reason }: {
|
|
153
|
+
message: string;
|
|
154
|
+
permission: string;
|
|
155
|
+
reason: string;
|
|
156
|
+
}, _optional?: Record<string, never>);
|
|
141
157
|
}
|
|
142
158
|
/**
|
|
143
159
|
* Execute a mutation through the command gateway.
|
|
@@ -146,20 +162,23 @@ export declare class ForbiddenError extends Error {
|
|
|
146
162
|
* If execute throws, nothing is recorded; if inverse capture throws, the
|
|
147
163
|
* mutation never runs.
|
|
148
164
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
165
|
+
* With a working `mutation.rollback`, the feature mutation and its change-set
|
|
166
|
+
* record form one compensated unit of work : failures in version capture,
|
|
167
|
+
* record preparation or `changeSets.insert` after `execute` trigger rollback and
|
|
168
|
+
* rejection. When rollback is omitted, the gateway still
|
|
169
|
+
* rejects but cannot undo the applied feature mutation; a caller-owned outer
|
|
170
|
+
* transaction must supply atomicity. Record/event atomicity depends on the repo
|
|
171
|
+
* honoring its port contract.
|
|
154
172
|
*
|
|
155
|
-
* The `change-set.applied` event is passed
|
|
156
|
-
* separate `deps.outbox.enqueue
|
|
173
|
+
* The `change-set.applied` event is passed in `changeSets.insert`'s optional object, not a
|
|
174
|
+
* separate `deps.outbox.enqueue` call afterward — a durable adapter co-persists it inside the
|
|
157
175
|
* same transaction as the change-set record, so it can never land without a durable delivery
|
|
158
176
|
* record (or vice versa). This still does NOT cover the domain mutation itself
|
|
159
|
-
* (`mutation.execute
|
|
177
|
+
* (`mutation.execute` stays outside any shared transaction, covered only by the compensating
|
|
160
178
|
* rollback above), and it covers only this producer. See
|
|
161
179
|
* `docs/decisions/change-set-outbox-transaction-boundary.md` for the full rationale, including the
|
|
162
|
-
* other direct `OutboxPort.enqueue
|
|
180
|
+
* other direct `OutboxPort.enqueue` producers this decision deliberately leaves untouched.
|
|
181
|
+
* See docs/decisions/DR-006-mutation-audit-atomicity.md.
|
|
163
182
|
*/
|
|
164
183
|
export declare function executeCommand<TResult>(required: ExecuteCommandRequired<TResult>, _optional?: ExecuteCommandOptional): Promise<{
|
|
165
184
|
result: TResult;
|