@aotter/mantle 0.1.0-alpha.14 → 0.1.0-alpha.15
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.
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# ADR-0020: Builtin Handler Contracts and Matched Upsert
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted
|
|
4
|
+
|
|
5
|
+
**Date:** 2026-09-01
|
|
6
|
+
|
|
7
|
+
**Related:** [#765](https://github.com/aotter/mantle/issues/765),
|
|
8
|
+
[#766](https://github.com/aotter/mantle/issues/766),
|
|
9
|
+
ADR-0008, ADR-0010, ADR-0014, ADR-0019
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
Mantle Procedures can bind to standard entry-mutation operations using `handler.kind: builtin` (e.g. `create`, `update`, `delete`, `archive`, `upsert`).
|
|
14
|
+
|
|
15
|
+
Before this ADR:
|
|
16
|
+
1. **No static contract validation**: Procedure input JSON Schema contracts were not statically verified against the requirements of their builtin operations during manifest linking (`ValidateManifestsUseCase`). Invalid contracts (e.g. `update` missing `id` or `expectedVersion`, nullable union types like `type: ["string", "null"]`, or `archive` on operational Schemas) were only caught at runtime via `requireField` or runtime assertions, causing valid-looking manifests to fail during invocation.
|
|
17
|
+
2. **Missing natural key upsert (`handler.match`)**: Built-in `upsert` only supported legacy ID-based lookup (`id` + `expectedVersion`), requiring client knowledge of internal system IDs. Real-world domains require idempotent upserts matching natural keys (such as `slug`, `siteKey`, or composite unique keys like `[slug, locale]`).
|
|
18
|
+
3. **Concurrency and race conditions**: Matched upsert must handle concurrent writers safely. When two concurrent writers miss preflight lookup, storage-level unique constraints must catch the conflict and surface structured `CONFLICT` diagnostics without retrying or corrupting entries.
|
|
19
|
+
|
|
20
|
+
## Decision
|
|
21
|
+
|
|
22
|
+
### 1. Static Contract Validation for Builtin Handlers
|
|
23
|
+
|
|
24
|
+
Manifest linking (`ManifestGraphValidator.ts`) now validates Procedure `input` schemas against the requirements of their builtin `op`:
|
|
25
|
+
|
|
26
|
+
- **Diagnostic Code**: `BUILTIN_HANDLER_CONTRACT_INVALID` (phase: `validate`).
|
|
27
|
+
- **Common rule**: `input` must be an `object` schema (`type: "object"`).
|
|
28
|
+
- **`update`**:
|
|
29
|
+
- `properties.id` must be strict, non-nullable `string` (`type: "string"`, not union array, not `nullable: true`).
|
|
30
|
+
- `properties.expectedVersion` must be strict, non-nullable `number` (`type: "number"`, not union array, not `nullable: true`).
|
|
31
|
+
- `required` must include both `"id"` and `"expectedVersion"`.
|
|
32
|
+
- **`delete`**:
|
|
33
|
+
- `properties.id` must be strict, non-nullable `string`.
|
|
34
|
+
- `required` must include `"id"`.
|
|
35
|
+
- **`archive`**:
|
|
36
|
+
- Target Schema must have `lifecycle: "publishing"` (operational Schemas have no lifecycle transitions and cannot be archived).
|
|
37
|
+
- `properties.id` must be strict, non-nullable `string`.
|
|
38
|
+
- `required` must include `"id"`.
|
|
39
|
+
- **`upsert` (legacy mode without `match`)**:
|
|
40
|
+
- If either `id` or `expectedVersion` is declared, both must be declared with strict string and number types respectively. Neither is in `required` because the create branch accepts new entries without IDs.
|
|
41
|
+
|
|
42
|
+
### 2. Matched Upsert Grammar and Static Contract (`handler.match`)
|
|
43
|
+
|
|
44
|
+
Procedures with `op: "upsert"` may declare `match`:
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
48
|
+
kind: Procedure
|
|
49
|
+
metadata:
|
|
50
|
+
name: upsertArticleBySlug
|
|
51
|
+
spec:
|
|
52
|
+
input:
|
|
53
|
+
type: object
|
|
54
|
+
properties:
|
|
55
|
+
slug:
|
|
56
|
+
type: string
|
|
57
|
+
title:
|
|
58
|
+
type: string
|
|
59
|
+
body:
|
|
60
|
+
type: string
|
|
61
|
+
required:
|
|
62
|
+
- slug
|
|
63
|
+
- title
|
|
64
|
+
output:
|
|
65
|
+
type: object
|
|
66
|
+
handler:
|
|
67
|
+
kind: builtin
|
|
68
|
+
op: upsert
|
|
69
|
+
schema: articles
|
|
70
|
+
match:
|
|
71
|
+
- slug
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Grammar and parser validation rules (`ManifestParser.ts`)**:
|
|
75
|
+
- `match` is allowed in `spec.handler` only when `handler.kind === "builtin"` and `op === "upsert"`.
|
|
76
|
+
- `match` must be a non-empty array of unique, non-empty field names.
|
|
77
|
+
|
|
78
|
+
**Static manifest validation rules (`ManifestGraphValidator.ts`)**:
|
|
79
|
+
- `handler.match` must exactly match one declared unique index in the target Schema's `spec.uniqueIndexes` (matching length, field names, and order).
|
|
80
|
+
- Every field in `handler.match` must be declared in the target Schema's `spec.schema.properties`.
|
|
81
|
+
- Every field in `handler.match` must be declared in Procedure `input.properties` and listed in `input.required`.
|
|
82
|
+
- Procedure `input` must NOT declare `id` or `expectedVersion` when using `match`.
|
|
83
|
+
|
|
84
|
+
### 3. Runtime Semantics and Atomic Conflict Handling (`InvokeBuiltinUseCase.ts`)
|
|
85
|
+
|
|
86
|
+
When executing matched upsert:
|
|
87
|
+
1. Extract matching field values from `input`.
|
|
88
|
+
2. Query existing entry using `entries.findByDataFields({ collection, fields })`.
|
|
89
|
+
3. **If found**: Execute update path (`opUpdate`):
|
|
90
|
+
- Merge input into existing data using `projectUpdateAndStamp`, preserving omitted fields and system bindings.
|
|
91
|
+
- Use `existing.id` and `existing.version` for optimistic concurrency control (OCC).
|
|
92
|
+
- Pass caller's original input to lifecycle hooks (`before_update` / `after_update`).
|
|
93
|
+
4. **If not found**: Execute create path (`opCreate`):
|
|
94
|
+
- Project and stamp data using `projectAndStamp`.
|
|
95
|
+
- Set status to `"published"` (if `lifecycle: "operational"`) or `"draft"` (if `lifecycle: "publishing"`).
|
|
96
|
+
- Pass caller's original input to lifecycle hooks (`before_create` / `after_create`).
|
|
97
|
+
5. **Concurrency & Race Conditions**:
|
|
98
|
+
- Both update and create operations are wrapped in `withConflictDiagnostic`.
|
|
99
|
+
- Storage-level unique constraint violations (e.g. SQLite `SQLITE_CONSTRAINT_UNIQUE`, Postgres `23505`) and version/status mismatches are converted into structured `CONFLICT` diagnostics (HTTP 409).
|
|
100
|
+
- The runtime does not automatically retry matched upsert on conflict, providing deterministic failure semantics under race conditions.
|
|
101
|
+
|
|
102
|
+
## Consequences
|
|
103
|
+
|
|
104
|
+
- **Fail-fast authoring**: Schema and Procedure mismatches are detected during `mantle validate` or test time rather than failing unpredictably at runtime.
|
|
105
|
+
- **Strict type safety**: Nullable union types cannot bypass static contract validation.
|
|
106
|
+
- **Natural key idempotency**: Clients can author natural-key upsert procedures for ingestion and sync pipelines without managing internal mantle IDs.
|
|
107
|
+
- **Safe concurrency across all adapters**: Concurrent writes are guarded by storage-level unique constraints (SQLite/D1 unique indexes, IndexedDB readwrite transaction assertions) and translated to standard `CONFLICT` diagnostics via typed `EntryUniqueConflict`.
|
|
108
|
+
|
|
109
|
+
## Alternatives considered
|
|
110
|
+
|
|
111
|
+
1. **Automatic retry loops on conflict**: Considered having `InvokeBuiltinUseCase` automatically re-query and retry upon catching unique collisions. Rejected because repeated attempts could fire lifecycle hooks (`before_create` / `before_update`) multiple times with unintended side effects (e.g. duplicate webhook notifications or rate limits) and mask true contention. Failing fast with a structured `CONFLICT` diagnostic gives caller control.
|
|
112
|
+
2. **Arbitrary field matching without Schema unique indexes**: Considered allowing `handler.match` on arbitrary Schema properties. Rejected because storage engines cannot enforce uniqueness without dedicated unique constraints/indexes, which would result in race conditions and duplicate entries under concurrent traffic.
|
|
113
|
+
3. **Permissive nullable unions**: Considered allowing `type: ["string", "null"]` in Procedure input schemas for `id`. Rejected because runtime entry mutations strictly require non-null scalar identifiers; allowing nullable schemas would let schema-valid requests crash inside builtin handlers.
|
|
114
|
+
|
|
115
|
+
## How to apply
|
|
116
|
+
|
|
117
|
+
1. **Declare Schema Unique Indexes**:
|
|
118
|
+
```yaml
|
|
119
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
120
|
+
kind: Schema
|
|
121
|
+
metadata:
|
|
122
|
+
name: site-settings
|
|
123
|
+
spec:
|
|
124
|
+
title: Site Settings
|
|
125
|
+
lifecycle: publishing
|
|
126
|
+
schema:
|
|
127
|
+
type: object
|
|
128
|
+
required: [siteKey, theme]
|
|
129
|
+
properties:
|
|
130
|
+
siteKey: { type: string }
|
|
131
|
+
theme: { type: string }
|
|
132
|
+
uniqueIndexes:
|
|
133
|
+
- [siteKey]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
2. **Author a Matched Upsert Procedure**:
|
|
137
|
+
```yaml
|
|
138
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
139
|
+
kind: Procedure
|
|
140
|
+
metadata:
|
|
141
|
+
name: setSiteSetting
|
|
142
|
+
spec:
|
|
143
|
+
input:
|
|
144
|
+
type: object
|
|
145
|
+
required: [siteKey, theme]
|
|
146
|
+
properties:
|
|
147
|
+
siteKey: { type: string }
|
|
148
|
+
theme: { type: string }
|
|
149
|
+
output:
|
|
150
|
+
type: object
|
|
151
|
+
handler:
|
|
152
|
+
kind: builtin
|
|
153
|
+
op: upsert
|
|
154
|
+
schema: site-settings
|
|
155
|
+
match: [siteKey]
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
3. **Validate**:
|
|
159
|
+
Run `mantle validate` to verify input contracts against the declared unique indexes and schema properties.
|
|
160
|
+
|
|
161
|
+
## Implementation status
|
|
162
|
+
|
|
163
|
+
- **`@aotter/mantle-spec`**:
|
|
164
|
+
- `ManifestGrammar.ts`: Added `match?: readonly string[]` to `HandlerBuiltinBinding`.
|
|
165
|
+
- `diagnostic.ts`: Added `BUILTIN_HANDLER_CONTRACT_INVALID` to `DIAGNOSTIC_CODES`.
|
|
166
|
+
- `ManifestParser.ts`: Parser-level validation for `handler.match`.
|
|
167
|
+
- `ManifestGraphValidator.ts`: Static input contract validation in `checkBuiltinHandler`.
|
|
168
|
+
- **`@aotter/mantle-runtime`**:
|
|
169
|
+
- `EntryRow.ts`: Added `EntryUniqueConflict` domain error.
|
|
170
|
+
- `EntryMutationDiagnostics.ts`: `withConflictDiagnostic` converts `EntryUniqueConflict` to `CONFLICT` diagnostic (HTTP 409).
|
|
171
|
+
- `DatabaseEntryRepository.ts`: SQLite/Postgres unique constraint violation translation to `EntryUniqueConflict`.
|
|
172
|
+
- `InvokeBuiltinUseCase.ts`: Matched upsert execution via `findByDataFields` and `projectUpdateAndStamp`.
|
|
173
|
+
- **`@aotter/mantle-indexeddb`**:
|
|
174
|
+
- `IndexedDbEntryRepository.ts`: Atomic transaction-level unique index enforcement and `EntryUniqueConflict` emission.
|
|
175
|
+
- **Documentation**:
|
|
176
|
+
- `docs/adr/0020-builtin-handler-contracts-and-matched-upsert.md` (this document).
|
|
177
|
+
- `docs/adr/README.md`.
|
|
178
|
+
- `docs/design-atoms.md`.
|
package/docs/adr/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Records of *why* mantle ended up shaped this way. The numbering preserves POC AD
|
|
|
20
20
|
| [0017](0017-media-multi-variant-agent-side-optimization.md) | Multi-variant media assets with agent-side optimization and asset-id entry references. | Accepted |
|
|
21
21
|
| [0018](0018-core-starters-repository-boundary.md) | Core produces published SDK artifacts; the separate starters repository validates them as an external consumer. Revisit after release-contract simplification. | Accepted for now |
|
|
22
22
|
| [0019](0019-sealed-manifest-runtime-pipeline.md) | One sealed source-to-runtime pipeline, semantic storage seam, and optional Web/Admin/platform dependency direction. | Accepted |
|
|
23
|
+
| [0020](0020-builtin-handler-contracts-and-matched-upsert.md) | Static builtin handler contracts and natural-key matched upsert (`handler.match`). | Accepted |
|
|
23
24
|
|
|
24
25
|
## Reading order
|
|
25
26
|
|
package/docs/design-atoms.md
CHANGED
|
@@ -690,24 +690,27 @@ spec:
|
|
|
690
690
|
kind: builtin
|
|
691
691
|
op: create | update | upsert | delete | archive
|
|
692
692
|
schema: <Schema metadata.name>
|
|
693
|
+
match: [ <field>, ... ] # optional; only valid for op: upsert
|
|
693
694
|
```
|
|
694
695
|
|
|
695
|
-
| op | Behavior |
|
|
696
|
-
|
|
697
|
-
| `create` | INSERT a new row. Project `input ∩ Schema.spec.schema.properties`; stamp `x-mantle-bind` fields; generated id; status is `draft`, or immediately `published` for `lifecycle: operational`. |
|
|
698
|
-
| `update` | UPDATE in place. `input.id`
|
|
699
|
-
| `upsert` | If `input.id` resolves,
|
|
700
|
-
| `delete` | Hard DELETE by id. |
|
|
701
|
-
| `archive` | Soft-archive a publishing entry (`status='archived'`). |
|
|
696
|
+
| op | Behavior | Input Contract Requirements |
|
|
697
|
+
|---|---|---|
|
|
698
|
+
| `create` | INSERT a new row. Project `input ∩ Schema.spec.schema.properties`; stamp `x-mantle-bind` fields; generated id; status is `draft`, or immediately `published` for `lifecycle: operational`. | `input` must be an object schema. |
|
|
699
|
+
| `update` | UPDATE in place. Merges patch with existing data via `projectUpdateAndStamp`. Bumps version. | `input.id` (strict string) and `input.expectedVersion` (strict number) are required in `input.required`. |
|
|
700
|
+
| `upsert` | If `match` is declared, queries by matched natural key fields: if found, updates the row via `projectUpdateAndStamp` using the row's existing version; if not found, creates a new row. If `match` is omitted (legacy), updates when `input.id` resolves, else creates. | If `match` is set, matched fields must match a declared unique index on the Schema, be declared in `input.properties`, and appear in `input.required`; `id` and `expectedVersion` must NOT be in `input`. If `match` is omitted and `id` or `expectedVersion` is declared, both must be declared with strict string/number types. |
|
|
701
|
+
| `delete` | Hard DELETE by id. | `input.id` (strict string) is required in `input.required`. |
|
|
702
|
+
| `archive` | Soft-archive a publishing entry (`status='archived'`). | Valid only on Schemas with `lifecycle: publishing`. `input.id` (strict string) is required in `input.required`. |
|
|
702
703
|
|
|
703
704
|
The Procedure's `input` is the contract with the *caller*. It MAY
|
|
704
705
|
declare fields the Schema does not (e.g. a Turnstile token). The
|
|
705
706
|
builtin op silently projects `input ∩ Schema.properties` and ignores
|
|
706
707
|
the rest; JSON Schema's default `additionalProperties: true` lets
|
|
707
708
|
the side-channel fields pass validation. To act on those fields
|
|
708
|
-
(read the token, call the vendor), declare a `before_create`
|
|
709
|
+
(read the token, call the vendor), declare a `before_create` or `before_update`
|
|
709
710
|
lifecycle Trigger — see below.
|
|
710
711
|
|
|
712
|
+
Under concurrent execution, database-level unique constraints catch any race conditions where concurrent writers miss preflight lookup, surfacing as standard `CONFLICT` diagnostics (HTTP 409) without automatic retries.
|
|
713
|
+
|
|
711
714
|
`request_publish` and `publish` are intentionally not in the builtin
|
|
712
715
|
vocabulary. They are lifecycle operations, not CRUD primitives.
|
|
713
716
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aotter/mantle",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.15",
|
|
4
4
|
"description": "Embeddable Mantle Core umbrella with Spec and Runtime; Web, Admin, Bun, Vercel, Cloudflare, and Admin UI are optional peer packages.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://mantle.tools/",
|
|
@@ -83,8 +83,8 @@
|
|
|
83
83
|
"README.md"
|
|
84
84
|
],
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@aotter/mantle-
|
|
87
|
-
"@aotter/mantle-
|
|
86
|
+
"@aotter/mantle-runtime": "0.1.0-alpha.15",
|
|
87
|
+
"@aotter/mantle-spec": "0.1.0-alpha.15"
|
|
88
88
|
},
|
|
89
89
|
"peerDependencies": {
|
|
90
90
|
"aws4fetch": "^1.0.20",
|
|
@@ -92,12 +92,12 @@
|
|
|
92
92
|
"hono": "^4.12.0",
|
|
93
93
|
"@libsql/client": "^0.17.4",
|
|
94
94
|
"zod": "^4.5.0",
|
|
95
|
-
"@aotter/mantle-
|
|
96
|
-
"@aotter/mantle-
|
|
97
|
-
"@aotter/mantle-
|
|
98
|
-
"@aotter/mantle-
|
|
99
|
-
"@aotter/mantle-
|
|
100
|
-
"@aotter/mantle-
|
|
95
|
+
"@aotter/mantle-bun": "0.1.0-alpha.15",
|
|
96
|
+
"@aotter/mantle-admin-ui": "0.1.0-alpha.15",
|
|
97
|
+
"@aotter/mantle-cloudflare": "0.1.0-alpha.15",
|
|
98
|
+
"@aotter/mantle-admin": "0.1.0-alpha.15",
|
|
99
|
+
"@aotter/mantle-web": "0.1.0-alpha.15",
|
|
100
|
+
"@aotter/mantle-vercel": "0.1.0-alpha.15"
|
|
101
101
|
},
|
|
102
102
|
"peerDependenciesMeta": {
|
|
103
103
|
"@aotter/mantle-admin": {
|
|
@@ -135,17 +135,17 @@
|
|
|
135
135
|
"@types/node": "^26",
|
|
136
136
|
"aws4fetch": "^1.0.20",
|
|
137
137
|
"better-auth": "^1.7.1",
|
|
138
|
-
"hono": "^4.13.
|
|
138
|
+
"hono": "^4.13.3",
|
|
139
139
|
"@libsql/client": "^0.17.4",
|
|
140
140
|
"typescript": "^6.0.3",
|
|
141
|
-
"vitest": "^4.1.
|
|
141
|
+
"vitest": "^4.1.11",
|
|
142
142
|
"zod": "^4.5.4",
|
|
143
|
-
"@aotter/mantle-admin": "0.1.0-alpha.
|
|
144
|
-
"@aotter/mantle-admin-ui": "0.1.0-alpha.
|
|
145
|
-
"@aotter/mantle-
|
|
146
|
-
"@aotter/mantle-
|
|
147
|
-
"@aotter/mantle-vercel": "0.1.0-alpha.
|
|
148
|
-
"@aotter/mantle-web": "0.1.0-alpha.
|
|
143
|
+
"@aotter/mantle-admin": "0.1.0-alpha.15",
|
|
144
|
+
"@aotter/mantle-admin-ui": "0.1.0-alpha.15",
|
|
145
|
+
"@aotter/mantle-cloudflare": "0.1.0-alpha.15",
|
|
146
|
+
"@aotter/mantle-bun": "0.1.0-alpha.15",
|
|
147
|
+
"@aotter/mantle-vercel": "0.1.0-alpha.15",
|
|
148
|
+
"@aotter/mantle-web": "0.1.0-alpha.15"
|
|
149
149
|
},
|
|
150
150
|
"engines": {
|
|
151
151
|
"node": ">=22"
|