effect-dynamodb 1.0.0 → 1.2.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/dist/DynamoClient.d.ts +1 -1
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +109 -35
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +447 -86
- package/dist/Entity.js.map +1 -1
- package/dist/KeyComposer.d.ts +81 -17
- package/dist/KeyComposer.d.ts.map +1 -1
- package/dist/KeyComposer.js +89 -40
- package/dist/KeyComposer.js.map +1 -1
- package/dist/Marshaller.d.ts.map +1 -1
- package/dist/Marshaller.js +1 -1
- package/dist/Marshaller.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/BoundCrud.d.ts +181 -0
- package/dist/internal/BoundCrud.d.ts.map +1 -0
- package/dist/internal/BoundCrud.js +160 -0
- package/dist/internal/BoundCrud.js.map +1 -0
- package/dist/internal/EntityConfig.d.ts +33 -0
- package/dist/internal/EntityConfig.d.ts.map +1 -1
- package/dist/internal/EntitySchemas.d.ts +14 -3
- package/dist/internal/EntitySchemas.d.ts.map +1 -1
- package/dist/internal/EntitySchemas.js +41 -2
- package/dist/internal/EntitySchemas.js.map +1 -1
- package/dist/internal/EntityTypes.d.ts +35 -3
- package/dist/internal/EntityTypes.d.ts.map +1 -1
- package/dist/internal/TransactableOps.d.ts +1 -1
- package/dist/internal/TransactableOps.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/Entity.js
CHANGED
|
@@ -11,6 +11,7 @@ import { DynamoClient } from "./DynamoClient.js";
|
|
|
11
11
|
import { getEncoding, getIdentifierField, isConfiguredModel, isHidden, isRef, isRefField, } from "./DynamoModel.js";
|
|
12
12
|
import * as DynamoSchema from "./DynamoSchema.js";
|
|
13
13
|
import { CascadePartialFailure, ConditionalCheckFailed, ItemNotFound, isAwsConditionalCheckFailed, isAwsTransactionCancelled, OptimisticLockError, RefNotFound, TransactionOverflow, UniqueConstraintViolation, ValidationError, } from "./Errors.js";
|
|
14
|
+
import { makeBoundDelete, makeBoundPut, makeBoundUpdate } from "./internal/BoundCrud.js";
|
|
14
15
|
import { BoundQueryImpl } from "./internal/BoundQuery.js";
|
|
15
16
|
import { compileExpr, createConditionOps, isExpr, parseShorthand, parseSimpleShorthand, } from "./internal/Expr.js";
|
|
16
17
|
import { compilePath, createPathBuilder } from "./internal/PathBuilder.js";
|
|
@@ -100,7 +101,7 @@ const makeImpl = (config) => {
|
|
|
100
101
|
const isSchemaClass = typeof rawModel === "function";
|
|
101
102
|
const modelFields = getFields(rawModel);
|
|
102
103
|
const hasHiddenFields = Object.values(modelFields).some(isHidden);
|
|
103
|
-
const systemFields = resolveSystemFields(config.timestamps, config.versioned);
|
|
104
|
+
const systemFields = resolveSystemFields(config.timestamps, config.versioned, config.timeSeries);
|
|
104
105
|
// ---------------------------------------------------------------------------
|
|
105
106
|
// Validate indexes
|
|
106
107
|
// ---------------------------------------------------------------------------
|
|
@@ -123,6 +124,82 @@ const makeImpl = (config) => {
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Validate timeSeries config (EDD-9010..9016)
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
if (config.timeSeries !== undefined && config.timeSeries !== null) {
|
|
131
|
+
const ts = config.timeSeries;
|
|
132
|
+
const orderBy = ts.orderBy;
|
|
133
|
+
// EDD-9010: orderBy must name a model field
|
|
134
|
+
if (!(orderBy in modelFields)) {
|
|
135
|
+
throw new Error(`[EDD-9010] Entity "${config.entityType}": timeSeries.orderBy "${orderBy}" does not name a model field. ` +
|
|
136
|
+
`Valid model fields: ${Object.keys(modelFields).sort().join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
// EDD-9011: orderBy must not be a primary-key composite (PK or SK)
|
|
139
|
+
const primary = config.indexes.primary;
|
|
140
|
+
const pkSkComposites = new Set([...primary.pk.composite, ...primary.sk.composite]);
|
|
141
|
+
if (pkSkComposites.has(orderBy)) {
|
|
142
|
+
throw new Error(`[EDD-9011] Entity "${config.entityType}": timeSeries.orderBy "${orderBy}" must not appear ` +
|
|
143
|
+
`in the primary key pk or sk composite — it shadows the #e# event-SK infix.`);
|
|
144
|
+
}
|
|
145
|
+
// EDD-9012: mutually exclusive with versioned
|
|
146
|
+
if (config.versioned !== undefined && config.versioned !== null && config.versioned !== false) {
|
|
147
|
+
throw new Error(`[EDD-9012] Entity "${config.entityType}": timeSeries and versioned are mutually exclusive. ` +
|
|
148
|
+
`Pick one consistency model per entity.`);
|
|
149
|
+
}
|
|
150
|
+
// EDD-9015: mutually exclusive with softDelete
|
|
151
|
+
if (config.softDelete !== undefined &&
|
|
152
|
+
config.softDelete !== null &&
|
|
153
|
+
config.softDelete !== false) {
|
|
154
|
+
throw new Error(`[EDD-9015] Entity "${config.entityType}": timeSeries and softDelete are mutually exclusive. ` +
|
|
155
|
+
`Append on a soft-deleted item would land on a new empty row — not a sound resurrection model.`);
|
|
156
|
+
}
|
|
157
|
+
// EDD-9016: appendInput is required
|
|
158
|
+
if (ts.appendInput === undefined || ts.appendInput === null) {
|
|
159
|
+
throw new Error(`[EDD-9016] Entity "${config.entityType}": timeSeries.appendInput is required. ` +
|
|
160
|
+
`Define a Schema.Struct whose fields are the subset of the model allowed in .append() input. ` +
|
|
161
|
+
`Fields outside appendInput are preserved on the current item — this is the enrichment-preservation guarantee. ` +
|
|
162
|
+
`To opt out (dangerous), pass the full model schema explicitly.`);
|
|
163
|
+
}
|
|
164
|
+
const appendInputFields = (() => {
|
|
165
|
+
const ai = ts.appendInput;
|
|
166
|
+
if ("fields" in ai && typeof ai.fields === "object") {
|
|
167
|
+
return Object.keys(ai.fields);
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`[EDD-9016] Entity "${config.entityType}": timeSeries.appendInput must be a Schema.Struct or Schema.Class (.fields required).`);
|
|
170
|
+
})();
|
|
171
|
+
const appendInputFieldSet = new Set(appendInputFields);
|
|
172
|
+
// EDD-9013: appendInput must include orderBy + all PK/SK composites
|
|
173
|
+
if (!appendInputFieldSet.has(orderBy)) {
|
|
174
|
+
throw new Error(`[EDD-9013] Entity "${config.entityType}": timeSeries.appendInput must include orderBy "${orderBy}". ` +
|
|
175
|
+
`Without it .append() cannot evaluate the CAS condition.`);
|
|
176
|
+
}
|
|
177
|
+
for (const composite of pkSkComposites) {
|
|
178
|
+
if (!appendInputFieldSet.has(composite)) {
|
|
179
|
+
throw new Error(`[EDD-9013] Entity "${config.entityType}": timeSeries.appendInput missing primary-key composite "${composite}". ` +
|
|
180
|
+
`Every PK/SK composite must appear in appendInput so the event can be addressed.`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// EDD-9014: orderBy must not name a ref field or ref-derived ${name}Id field
|
|
184
|
+
for (const fieldName of Object.keys(modelFields)) {
|
|
185
|
+
if (isRefField(fieldName, config.model)) {
|
|
186
|
+
if (orderBy === fieldName || orderBy === `${fieldName}Id`) {
|
|
187
|
+
throw new Error(`[EDD-9014] Entity "${config.entityType}": timeSeries.orderBy "${orderBy}" names a ref ` +
|
|
188
|
+
`or ref-derived id field. Refs are create-time denormalisations and cannot serve as the event clock.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Also: appendInput must not declare any ref-derived ${name}Id field —
|
|
193
|
+
// ref changes go through .update(), not .append() (§4.6).
|
|
194
|
+
for (const fieldName of Object.keys(modelFields)) {
|
|
195
|
+
if (isRefField(fieldName, config.model)) {
|
|
196
|
+
if (appendInputFieldSet.has(`${fieldName}Id`)) {
|
|
197
|
+
throw new Error(`[EDD-9014] Entity "${config.entityType}": timeSeries.appendInput must not include ref-derived ` +
|
|
198
|
+
`"${fieldName}Id" — refs cannot be reassigned via .append(). Use .update() to change a ref.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
126
203
|
const resolvedRefs = config.refs
|
|
127
204
|
? Object.entries(config.refs).map(([fieldName, refValue]) => {
|
|
128
205
|
// Extract entity from shorthand or expanded form
|
|
@@ -182,7 +259,7 @@ const makeImpl = (config) => {
|
|
|
182
259
|
}
|
|
183
260
|
// Resolve identifier field name from model annotation
|
|
184
261
|
const resolvedIdentifier = getIdentifierField(config.model)?.name;
|
|
185
|
-
const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier);
|
|
262
|
+
const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier, config.timeSeries);
|
|
186
263
|
// schema and tableTag are injected via _configure() when the entity is registered
|
|
187
264
|
// on a Table and bound through DynamoClient.make(). They are captured by operation
|
|
188
265
|
// closures and resolved at runtime (inside Effects), not at definition time.
|
|
@@ -1137,46 +1214,6 @@ const makeImpl = (config) => {
|
|
|
1137
1214
|
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1138
1215
|
counter++;
|
|
1139
1216
|
}
|
|
1140
|
-
// Shared sets for GSI removal detection (used by both compose and REMOVE)
|
|
1141
|
-
const removedSet = uState.remove ? new Set(uState.remove) : undefined;
|
|
1142
|
-
const pkCompositeSet = new Set(primaryKeyComposites(config.indexes));
|
|
1143
|
-
// Compose GSI keys for indexes whose composites are in the update,
|
|
1144
|
-
// filtering out any GSI whose composites are targeted by REMOVE
|
|
1145
|
-
const indexesForGsiUpdate = removedSet
|
|
1146
|
-
? (() => {
|
|
1147
|
-
const filtered = {};
|
|
1148
|
-
for (const [indexName, indexDef] of Object.entries(allIndexes)) {
|
|
1149
|
-
if (!("index" in indexDef)) {
|
|
1150
|
-
filtered[indexName] = indexDef;
|
|
1151
|
-
continue;
|
|
1152
|
-
}
|
|
1153
|
-
const gsiComposites = [...indexDef.pk.composite, ...indexDef.sk.composite].filter((attr) => !pkCompositeSet.has(attr));
|
|
1154
|
-
if (!gsiComposites.some((attr) => removedSet.has(attr))) {
|
|
1155
|
-
filtered[indexName] = indexDef;
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
return filtered;
|
|
1159
|
-
})()
|
|
1160
|
-
: allIndexes;
|
|
1161
|
-
let gsiKeys;
|
|
1162
|
-
try {
|
|
1163
|
-
gsiKeys = KeyComposer.composeGsiKeysForUpdate(schema, entityType, entityVersion, indexesForGsiUpdate, hydratedUpdates, decodedKey);
|
|
1164
|
-
}
|
|
1165
|
-
catch (e) {
|
|
1166
|
-
return yield* new ValidationError({
|
|
1167
|
-
entityType,
|
|
1168
|
-
operation: "update.gsiComposites",
|
|
1169
|
-
cause: e instanceof Error ? e.message : e,
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
for (const [field, value] of Object.entries(gsiKeys)) {
|
|
1173
|
-
const nameKey = `#u${counter}`;
|
|
1174
|
-
const valKey = `:u${counter}`;
|
|
1175
|
-
names[nameKey] = field;
|
|
1176
|
-
values[valKey] = toAttributeValue(value);
|
|
1177
|
-
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1178
|
-
counter++;
|
|
1179
|
-
}
|
|
1180
1217
|
// Add updatedAt timestamp (encoding-aware)
|
|
1181
1218
|
if (systemFields.updatedAt) {
|
|
1182
1219
|
const nameKey = `#u${counter}`;
|
|
@@ -1221,6 +1258,7 @@ const makeImpl = (config) => {
|
|
|
1221
1258
|
}
|
|
1222
1259
|
}
|
|
1223
1260
|
// REMOVE
|
|
1261
|
+
const removedSet = uState.remove ? new Set(uState.remove) : undefined;
|
|
1224
1262
|
if (uState.remove) {
|
|
1225
1263
|
for (const attr of uState.remove) {
|
|
1226
1264
|
const nameKey = `#r${removeClauses.length}`;
|
|
@@ -1228,22 +1266,23 @@ const makeImpl = (config) => {
|
|
|
1228
1266
|
removeClauses.push(nameKey);
|
|
1229
1267
|
}
|
|
1230
1268
|
}
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1269
|
+
// Policy-aware GSI key composition. One call covers SETs (full
|
|
1270
|
+
// recompose or half-wise), sparse dropout REMOVEs, and cascade
|
|
1271
|
+
// REMOVEs when an Entity.remove() targets a GSI composite.
|
|
1272
|
+
// See DESIGN.md §7 Policy-Aware GSI Composition.
|
|
1273
|
+
const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, hydratedUpdates, decodedKey, { removedSet });
|
|
1274
|
+
for (const [field, value] of Object.entries(gsiUpdate.sets)) {
|
|
1275
|
+
const nameKey = `#u${counter}`;
|
|
1276
|
+
const valKey = `:u${counter}`;
|
|
1277
|
+
names[nameKey] = field;
|
|
1278
|
+
values[valKey] = toAttributeValue(value);
|
|
1279
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1280
|
+
counter++;
|
|
1281
|
+
}
|
|
1282
|
+
for (const keyField of gsiUpdate.removes) {
|
|
1283
|
+
const nameKey = `#r${removeClauses.length}`;
|
|
1284
|
+
names[nameKey] = keyField;
|
|
1285
|
+
removeClauses.push(nameKey);
|
|
1247
1286
|
}
|
|
1248
1287
|
// ADD (atomic numeric increment / set addition)
|
|
1249
1288
|
if (uState.add) {
|
|
@@ -1727,6 +1766,292 @@ const makeImpl = (config) => {
|
|
|
1727
1766
|
return yield* decodeAs(raw, result.Attributes, mode);
|
|
1728
1767
|
}), self, input);
|
|
1729
1768
|
// ---------------------------------------------------------------------------
|
|
1769
|
+
// append operation — time-series primitive (only when `timeSeries` configured)
|
|
1770
|
+
//
|
|
1771
|
+
// Two-item `TransactWriteItems`:
|
|
1772
|
+
// - UpdateItem on current: scoped SET (appendInput fields only) + CAS on
|
|
1773
|
+
// `orderBy`, with GSI keys recomposed when any appendInput field is a
|
|
1774
|
+
// GSI composite. `createdAt` uses if_not_exists on first append.
|
|
1775
|
+
// - Put of event: full decoded input + __edd_e__ + _ttl (if configured),
|
|
1776
|
+
// GSI keys stripped, SK replaced with `<currentSk>#e#<orderByValue>`.
|
|
1777
|
+
//
|
|
1778
|
+
// On TransactionCancelled or ConditionalCheckFailed, we issue a follow-up
|
|
1779
|
+
// GetItem on the primary key and return `{ applied: false, reason: "stale",
|
|
1780
|
+
// current }`. On success, same follow-up GetItem yields the post-append
|
|
1781
|
+
// current. See `docs/designs/timeseries.md` §4.
|
|
1782
|
+
// ---------------------------------------------------------------------------
|
|
1783
|
+
const timeSeriesConfig = config.timeSeries;
|
|
1784
|
+
const append = (input, userCondition) => Effect.gen(function* () {
|
|
1785
|
+
if (!timeSeriesConfig) {
|
|
1786
|
+
return yield* new ValidationError({
|
|
1787
|
+
entityType,
|
|
1788
|
+
operation: "append",
|
|
1789
|
+
cause: "Entity is not configured with timeSeries. .append() requires timeSeries config.",
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
const client = yield* DynamoClient;
|
|
1793
|
+
const { name: tableName } = yield* tableTag;
|
|
1794
|
+
const orderByField = timeSeriesConfig.orderBy;
|
|
1795
|
+
const ttlDuration = timeSeriesConfig.ttl;
|
|
1796
|
+
const appendInputSchema = schemas.appendInputSchema;
|
|
1797
|
+
// Decode input via appendInputSchema
|
|
1798
|
+
const decodedInput = yield* Schema.decodeUnknownEffect(appendInputSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
|
|
1799
|
+
entityType,
|
|
1800
|
+
operation: "append.decode",
|
|
1801
|
+
cause,
|
|
1802
|
+
})));
|
|
1803
|
+
const decoded = decodedInput;
|
|
1804
|
+
// Compose current-item primary key (pk + sk derived from PK/SK composites in decoded)
|
|
1805
|
+
const primary = config.indexes.primary;
|
|
1806
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, decoded);
|
|
1807
|
+
const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decoded);
|
|
1808
|
+
const marshalledKey = toAttributeMap({
|
|
1809
|
+
[primary.pk.field]: pkValue,
|
|
1810
|
+
[primary.sk.field]: currentSk,
|
|
1811
|
+
});
|
|
1812
|
+
// Prepare domain-serialised values for the item build.
|
|
1813
|
+
// Build a shallow copy and apply date serialization (per fieldEncodings)
|
|
1814
|
+
// before marshalling. This mirrors the put path.
|
|
1815
|
+
const serialisedInput = { ...decoded };
|
|
1816
|
+
serializeDateFields(serialisedInput);
|
|
1817
|
+
// ---------- Build UpdateItem (scoped SET + CAS) ----------
|
|
1818
|
+
const setClauses = [];
|
|
1819
|
+
const names = {};
|
|
1820
|
+
const values = {};
|
|
1821
|
+
let counter = 0;
|
|
1822
|
+
// Only fields named in appendInput (the serialisedInput object). This is
|
|
1823
|
+
// the enrichment-preservation contract — fields outside appendInput are
|
|
1824
|
+
// never touched. PK composites ARE included: they're stored as regular
|
|
1825
|
+
// attributes on the item (mirrors `.put()`), and though their values
|
|
1826
|
+
// never change, writing them on every append makes the first append
|
|
1827
|
+
// (where the row doesn't yet exist) materialise the row correctly.
|
|
1828
|
+
const pkCompositeSet = new Set(primaryKeyComposites(config.indexes));
|
|
1829
|
+
for (const [attr, val] of Object.entries(serialisedInput)) {
|
|
1830
|
+
if (val === undefined)
|
|
1831
|
+
continue;
|
|
1832
|
+
const nameKey = `#a${counter}`;
|
|
1833
|
+
const valKey = `:a${counter}`;
|
|
1834
|
+
names[nameKey] = resolveDbName(attr);
|
|
1835
|
+
values[valKey] = toAttributeValue(val);
|
|
1836
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1837
|
+
counter++;
|
|
1838
|
+
}
|
|
1839
|
+
// GSI key recomposition: any GSI whose composites are touched by input.
|
|
1840
|
+
// Reuses the policy-aware helper from `.update()`. PK composites are
|
|
1841
|
+
// excluded from the update payload — they're already in the key and
|
|
1842
|
+
// never change during an append. Non-PK composites in appendInput that
|
|
1843
|
+
// overlap a GSI's composites trigger per-GSI resolution via indexPolicy.
|
|
1844
|
+
const nonPkAppendFields = {};
|
|
1845
|
+
for (const [attr, val] of Object.entries(decoded)) {
|
|
1846
|
+
if (!pkCompositeSet.has(attr)) {
|
|
1847
|
+
nonPkAppendFields[attr] = val;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
// At append-time, indexPolicy is restricted to composites that are
|
|
1851
|
+
// members of appendInput. Non-appendInput composites are owned by
|
|
1852
|
+
// other writers and are by contract never touched by .append(); their
|
|
1853
|
+
// policy cannot fire at append-time. Each GSI's indexPolicy return is
|
|
1854
|
+
// filtered to appendInput attrs only — non-appendInput keys fall through
|
|
1855
|
+
// to the implicit `"preserve"` default, leaving the half containing
|
|
1856
|
+
// them untouched.
|
|
1857
|
+
const appendInputFieldSet = new Set(Object.keys(appendInputSchema
|
|
1858
|
+
.fields ?? {}));
|
|
1859
|
+
const policyAwareIndexes = {};
|
|
1860
|
+
for (const [indexName, indexDef] of Object.entries(allIndexes)) {
|
|
1861
|
+
if (!indexDef.indexPolicy) {
|
|
1862
|
+
policyAwareIndexes[indexName] = indexDef;
|
|
1863
|
+
continue;
|
|
1864
|
+
}
|
|
1865
|
+
const originalPolicy = indexDef.indexPolicy;
|
|
1866
|
+
policyAwareIndexes[indexName] = {
|
|
1867
|
+
...indexDef,
|
|
1868
|
+
indexPolicy: (item) => {
|
|
1869
|
+
const raw = originalPolicy(item);
|
|
1870
|
+
const filtered = {};
|
|
1871
|
+
for (const [attr, policy] of Object.entries(raw)) {
|
|
1872
|
+
if (appendInputFieldSet.has(attr) && policy !== undefined) {
|
|
1873
|
+
filtered[attr] = policy;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
return filtered;
|
|
1877
|
+
},
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, decoded);
|
|
1881
|
+
for (const [field, value] of Object.entries(gsiUpdate.sets)) {
|
|
1882
|
+
const nameKey = `#a${counter}`;
|
|
1883
|
+
const valKey = `:a${counter}`;
|
|
1884
|
+
names[nameKey] = field;
|
|
1885
|
+
values[valKey] = toAttributeValue(value);
|
|
1886
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1887
|
+
counter++;
|
|
1888
|
+
}
|
|
1889
|
+
const appendRemoveClauses = [];
|
|
1890
|
+
for (const keyField of gsiUpdate.removes) {
|
|
1891
|
+
const nameKey = `#a${counter}`;
|
|
1892
|
+
names[nameKey] = keyField;
|
|
1893
|
+
appendRemoveClauses.push(nameKey);
|
|
1894
|
+
counter++;
|
|
1895
|
+
}
|
|
1896
|
+
// Entity type discriminator — idempotent; ensures existing items
|
|
1897
|
+
// without __edd_e__ still get tagged on first append.
|
|
1898
|
+
{
|
|
1899
|
+
const nameKey = `#a${counter}`;
|
|
1900
|
+
const valKey = `:a${counter}`;
|
|
1901
|
+
names[nameKey] = "__edd_e__";
|
|
1902
|
+
values[valKey] = toAttributeValue(entityType);
|
|
1903
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1904
|
+
counter++;
|
|
1905
|
+
}
|
|
1906
|
+
// createdAt (if configured) — if_not_exists so subsequent appends leave it alone
|
|
1907
|
+
if (systemFields.createdAt) {
|
|
1908
|
+
const nameKey = `#a${counter}`;
|
|
1909
|
+
const valKey = `:a${counter}`;
|
|
1910
|
+
names[nameKey] = systemFields.createdAt;
|
|
1911
|
+
values[valKey] = toAttributeValue(generateTimestamp(systemFields.createdAtEncoding));
|
|
1912
|
+
setClauses.push(`${nameKey} = if_not_exists(${nameKey}, ${valKey})`);
|
|
1913
|
+
counter++;
|
|
1914
|
+
}
|
|
1915
|
+
// CAS condition: attribute_not_exists(#pk) OR #ob < :newOb
|
|
1916
|
+
names["#_tspk"] = primary.pk.field;
|
|
1917
|
+
names["#_tsob"] = resolveDbName(orderByField);
|
|
1918
|
+
// The comparison uses the stored domain-value representation — for
|
|
1919
|
+
// DateTime.Utc this is ISO (lexicographic == chronological), for numbers
|
|
1920
|
+
// it's numeric, for strings it's lexicographic. Matches how the current
|
|
1921
|
+
// item stores `orderByField`.
|
|
1922
|
+
const newObValue = serialisedInput[orderByField];
|
|
1923
|
+
values[":_tsNewOb"] = toAttributeValue(newObValue);
|
|
1924
|
+
const casCondition = "attribute_not_exists(#_tspk) OR #_tsob < :_tsNewOb";
|
|
1925
|
+
let finalCondition = casCondition;
|
|
1926
|
+
if (userCondition !== undefined) {
|
|
1927
|
+
const uc = compileCondition(userCondition, resolveDbName);
|
|
1928
|
+
finalCondition = `(${casCondition}) AND (${uc.expression})`;
|
|
1929
|
+
Object.assign(names, uc.names);
|
|
1930
|
+
Object.assign(values, uc.values);
|
|
1931
|
+
}
|
|
1932
|
+
const exprParts = [];
|
|
1933
|
+
if (setClauses.length > 0)
|
|
1934
|
+
exprParts.push(`SET ${setClauses.join(", ")}`);
|
|
1935
|
+
if (appendRemoveClauses.length > 0)
|
|
1936
|
+
exprParts.push(`REMOVE ${appendRemoveClauses.join(", ")}`);
|
|
1937
|
+
const updateExpression = exprParts.join(" ");
|
|
1938
|
+
// ---------- Build Put of event item ----------
|
|
1939
|
+
const eventSk = KeyComposer.composeEventSk(currentSk, newObValue, schema.casing);
|
|
1940
|
+
const eventItem = { ...decoded };
|
|
1941
|
+
// Serialise date fields to storage format for the Put
|
|
1942
|
+
serializeDateFields(eventItem);
|
|
1943
|
+
// Rename domain → DB names for the Put (matches put path)
|
|
1944
|
+
renameToDynamo(eventItem);
|
|
1945
|
+
// pk + sk (event)
|
|
1946
|
+
eventItem[primary.pk.field] = pkValue;
|
|
1947
|
+
eventItem[primary.sk.field] = eventSk;
|
|
1948
|
+
// __edd_e__
|
|
1949
|
+
eventItem.__edd_e__ = entityType;
|
|
1950
|
+
// _ttl
|
|
1951
|
+
if (ttlDuration) {
|
|
1952
|
+
eventItem._ttl = Math.floor(Date.now() / 1000) + Duration.toSeconds(ttlDuration);
|
|
1953
|
+
}
|
|
1954
|
+
// Events never participate in indexes: strip any GSI key fields that the
|
|
1955
|
+
// naive spread above may have carried over. gsiKeys weren't written into
|
|
1956
|
+
// eventItem (only decoded fields are), but defensively clear them.
|
|
1957
|
+
for (const field of gsiKeyFields()) {
|
|
1958
|
+
delete eventItem[field];
|
|
1959
|
+
}
|
|
1960
|
+
const marshalledEventItem = toAttributeMap(eventItem);
|
|
1961
|
+
// ---------- Execute TransactWriteItems ----------
|
|
1962
|
+
const transactResult = yield* client
|
|
1963
|
+
.transactWriteItems({
|
|
1964
|
+
TransactItems: [
|
|
1965
|
+
{
|
|
1966
|
+
Update: {
|
|
1967
|
+
TableName: tableName,
|
|
1968
|
+
Key: marshalledKey,
|
|
1969
|
+
UpdateExpression: updateExpression,
|
|
1970
|
+
ConditionExpression: finalCondition,
|
|
1971
|
+
ExpressionAttributeNames: names,
|
|
1972
|
+
ExpressionAttributeValues: values,
|
|
1973
|
+
},
|
|
1974
|
+
},
|
|
1975
|
+
{
|
|
1976
|
+
Put: {
|
|
1977
|
+
TableName: tableName,
|
|
1978
|
+
Item: marshalledEventItem,
|
|
1979
|
+
},
|
|
1980
|
+
},
|
|
1981
|
+
],
|
|
1982
|
+
})
|
|
1983
|
+
.pipe(Effect.matchEffect({
|
|
1984
|
+
onSuccess: () => Effect.succeed({ stale: false }),
|
|
1985
|
+
onFailure: (err) => {
|
|
1986
|
+
// TransactionCancelled or ConditionalCheckFailed at the transaction
|
|
1987
|
+
// level both map to "stale" (can't distinguish CAS from user-cond
|
|
1988
|
+
// in v1 — see §12 decision 5).
|
|
1989
|
+
if (isAwsTransactionCancelled(err.cause) || isAwsConditionalCheckFailed(err.cause)) {
|
|
1990
|
+
return Effect.succeed({ stale: true });
|
|
1991
|
+
}
|
|
1992
|
+
return Effect.fail(err);
|
|
1993
|
+
},
|
|
1994
|
+
}));
|
|
1995
|
+
// Follow-up GetItem to read the current (post-append on success, winning
|
|
1996
|
+
// state on stale). One read is required either way — the contract
|
|
1997
|
+
// returns `Model` which demands the full row including enrichment.
|
|
1998
|
+
const followUp = yield* client.getItem({
|
|
1999
|
+
TableName: tableName,
|
|
2000
|
+
Key: marshalledKey,
|
|
2001
|
+
});
|
|
2002
|
+
if (!followUp.Item) {
|
|
2003
|
+
// TTL race or out-of-band delete — surface as a DynamoClientError
|
|
2004
|
+
// (the previous state vanished under us). This is NOT a stale result.
|
|
2005
|
+
return yield* Effect.fail(new ValidationError({
|
|
2006
|
+
entityType,
|
|
2007
|
+
operation: "append.followUp",
|
|
2008
|
+
cause: "Current item not found after append — possible TTL race or concurrent delete.",
|
|
2009
|
+
}));
|
|
2010
|
+
}
|
|
2011
|
+
const rawCurrent = fromAttributeMap(followUp.Item);
|
|
2012
|
+
const currentModel = yield* decodeAs(rawCurrent, followUp.Item, "model");
|
|
2013
|
+
if (transactResult.stale) {
|
|
2014
|
+
return {
|
|
2015
|
+
applied: false,
|
|
2016
|
+
reason: "stale",
|
|
2017
|
+
current: currentModel,
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
return { applied: true, current: currentModel };
|
|
2021
|
+
});
|
|
2022
|
+
// ---------------------------------------------------------------------------
|
|
2023
|
+
// history operation — BoundQuery for event items only
|
|
2024
|
+
// ---------------------------------------------------------------------------
|
|
2025
|
+
const history = (key) => {
|
|
2026
|
+
if (!timeSeriesConfig) {
|
|
2027
|
+
throw new Error(`[EDD-9010] Entity "${entityType}": .history() requires timeSeries config on the entity.`);
|
|
2028
|
+
}
|
|
2029
|
+
const decodedKey = Schema.decodeUnknownSync(schemas.keySchema)(key);
|
|
2030
|
+
const primary = config.indexes.primary;
|
|
2031
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
|
|
2032
|
+
const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decodedKey);
|
|
2033
|
+
const prefix = KeyComposer.composeEventSkPrefix(currentSk, schema.casing);
|
|
2034
|
+
const decodeHistory = (raw) => {
|
|
2035
|
+
renameFromDynamo(raw);
|
|
2036
|
+
deserializeDateFields(raw);
|
|
2037
|
+
return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
2038
|
+
entityType,
|
|
2039
|
+
operation: "history.decode",
|
|
2040
|
+
cause,
|
|
2041
|
+
})));
|
|
2042
|
+
};
|
|
2043
|
+
return Query.make({
|
|
2044
|
+
tableName: "",
|
|
2045
|
+
indexName: undefined,
|
|
2046
|
+
pkField: primary.pk.field,
|
|
2047
|
+
pkValue,
|
|
2048
|
+
skField: primary.sk.field,
|
|
2049
|
+
entityTypes: [entityType],
|
|
2050
|
+
decoder: (raw) => decodeHistory(raw),
|
|
2051
|
+
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
2052
|
+
}).pipe(Query.where({ beginsWith: prefix }));
|
|
2053
|
+
};
|
|
2054
|
+
// ---------------------------------------------------------------------------
|
|
1730
2055
|
// query namespace
|
|
1731
2056
|
// ---------------------------------------------------------------------------
|
|
1732
2057
|
const queryNamespace = {};
|
|
@@ -2170,6 +2495,7 @@ const makeImpl = (config) => {
|
|
|
2170
2495
|
softDelete: config.softDelete,
|
|
2171
2496
|
unique: config.unique,
|
|
2172
2497
|
identifier: resolvedIdentifier,
|
|
2498
|
+
timeSeries: config.timeSeries,
|
|
2173
2499
|
_resolvedRefs: resolvedRefs,
|
|
2174
2500
|
/** @internal Full decode pipeline: rename + date deser + schema decode. Used by Batch/Aggregate. */
|
|
2175
2501
|
_decodeRecord: decodeRecord,
|
|
@@ -2206,6 +2532,8 @@ const makeImpl = (config) => {
|
|
|
2206
2532
|
restore,
|
|
2207
2533
|
purge,
|
|
2208
2534
|
deleted: { get: deletedGet, list: deletedList },
|
|
2535
|
+
append,
|
|
2536
|
+
history,
|
|
2209
2537
|
set,
|
|
2210
2538
|
expectedVersion,
|
|
2211
2539
|
query: queryNamespace,
|
|
@@ -2238,26 +2566,12 @@ const makeImpl = (config) => {
|
|
|
2238
2566
|
export const bind = (entity) => Effect.gen(function* () {
|
|
2239
2567
|
const ctx = yield* Effect.context();
|
|
2240
2568
|
const provide = (effect) => Effect.provide(effect, ctx);
|
|
2241
|
-
//
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
};
|
|
2248
|
-
// Helper: apply combinators to an EntityPut, then run in record mode + provide
|
|
2249
|
-
const applyPut = (op, combinators) => {
|
|
2250
|
-
let result = op;
|
|
2251
|
-
for (const fn of combinators)
|
|
2252
|
-
result = fn(result);
|
|
2253
|
-
return provide(result._run("record"));
|
|
2254
|
-
};
|
|
2255
|
-
// Helper: apply combinators to an EntityDelete, then asEffect + provide
|
|
2256
|
-
const applyDelete = (op, combinators) => {
|
|
2257
|
-
let result = op;
|
|
2258
|
-
for (const fn of combinators)
|
|
2259
|
-
result = fn(result);
|
|
2260
|
-
return provide(result.asEffect());
|
|
2569
|
+
// Shared config for the bound-CRUD builders — pre-resolved services plus
|
|
2570
|
+
// a typed PathBuilder/ConditionOps so `.condition((t, ops) => ...)` works.
|
|
2571
|
+
const boundCrudConfig = {
|
|
2572
|
+
pathBuilder: createPathBuilder(),
|
|
2573
|
+
conditionOps: createConditionOps(),
|
|
2574
|
+
provide,
|
|
2261
2575
|
};
|
|
2262
2576
|
// Helper: apply query combinators then execute
|
|
2263
2577
|
const applyQuery = (q, combinators) => {
|
|
@@ -2279,15 +2593,15 @@ export const bind = (entity) => Effect.gen(function* () {
|
|
|
2279
2593
|
return new BoundQueryImpl(q, bqConfig);
|
|
2280
2594
|
};
|
|
2281
2595
|
return {
|
|
2282
|
-
// CRUD —
|
|
2596
|
+
// CRUD — fluent bound builders (yieldable, no .run() terminal)
|
|
2283
2597
|
get: (key) => provide(entity.get(key)._run("record")),
|
|
2284
|
-
put: (input
|
|
2285
|
-
create: (input
|
|
2286
|
-
update: (key
|
|
2287
|
-
delete: (key
|
|
2288
|
-
upsert: (input
|
|
2289
|
-
patch: (key
|
|
2290
|
-
deleteIfExists: (key
|
|
2598
|
+
put: (input) => makeBoundPut(entity.put(input), boundCrudConfig),
|
|
2599
|
+
create: (input) => makeBoundPut(entity.create(input), boundCrudConfig),
|
|
2600
|
+
update: (key) => makeBoundUpdate(entity.update(key), boundCrudConfig),
|
|
2601
|
+
delete: (key) => makeBoundDelete(entity.delete(key), boundCrudConfig),
|
|
2602
|
+
upsert: (input) => makeBoundPut(entity.upsert(input), boundCrudConfig),
|
|
2603
|
+
patch: (key) => makeBoundUpdate(entity.patch(key), boundCrudConfig),
|
|
2604
|
+
deleteIfExists: (key) => makeBoundDelete(entity.deleteIfExists(key), boundCrudConfig),
|
|
2291
2605
|
// Lifecycle
|
|
2292
2606
|
getVersion: (key, version) => provide(entity.getVersion(key, version)._run("record")),
|
|
2293
2607
|
versions: (key) => wrapAsBoundQuery(entity.versions(key)),
|
|
@@ -2297,6 +2611,53 @@ export const bind = (entity) => Effect.gen(function* () {
|
|
|
2297
2611
|
get: (key) => provide(entity.deleted.get(key)._run("record")),
|
|
2298
2612
|
list: (key) => wrapAsBoundQuery(entity.deleted.list(key)),
|
|
2299
2613
|
},
|
|
2614
|
+
// Time-series (no-ops when entity is not configured; runtime check in
|
|
2615
|
+
// the entity-level `append` returns a ValidationError).
|
|
2616
|
+
append: (input, condition) => provide(entity.append(input, condition)),
|
|
2617
|
+
history: (key) => {
|
|
2618
|
+
const q = entity.history(key);
|
|
2619
|
+
const pathBuilder = createPathBuilder();
|
|
2620
|
+
const conditionOps = createConditionOps();
|
|
2621
|
+
const ts = entity.timeSeries;
|
|
2622
|
+
const orderBy = ts?.orderBy;
|
|
2623
|
+
const entityInternals = entity;
|
|
2624
|
+
const schemaRef = entityInternals._schema;
|
|
2625
|
+
const entityTypeRef = entityInternals.entityType;
|
|
2626
|
+
// composeSkCondition: rewrite user's .where() values by prefixing
|
|
2627
|
+
// with `<currentSk>#e#` and applying serialisation + casing to the
|
|
2628
|
+
// user-supplied orderBy value.
|
|
2629
|
+
const primary = entity.indexes.primary;
|
|
2630
|
+
const composeSkCondition = (cond) => {
|
|
2631
|
+
// We need the `currentSk` + prefix. The user's `key` lets us derive
|
|
2632
|
+
// `currentSk` via the same primary SK composer used by `history()`.
|
|
2633
|
+
const currentSk = KeyComposer.composeSk(schemaRef, entityTypeRef, 1, primary, key);
|
|
2634
|
+
const prefix = KeyComposer.composeEventSkPrefix(currentSk, schemaRef.casing);
|
|
2635
|
+
const rewrite = (v) => `${prefix}${DynamoSchema.applyCasing(KeyComposer.serializeValue(v), schemaRef.casing)}`;
|
|
2636
|
+
if ("eq" in cond)
|
|
2637
|
+
return { eq: rewrite(cond.eq) };
|
|
2638
|
+
if ("lt" in cond)
|
|
2639
|
+
return { lt: rewrite(cond.lt) };
|
|
2640
|
+
if ("lte" in cond)
|
|
2641
|
+
return { lte: rewrite(cond.lte) };
|
|
2642
|
+
if ("gt" in cond)
|
|
2643
|
+
return { gt: rewrite(cond.gt) };
|
|
2644
|
+
if ("gte" in cond)
|
|
2645
|
+
return { gte: rewrite(cond.gte) };
|
|
2646
|
+
if ("between" in cond)
|
|
2647
|
+
return { between: [rewrite(cond.between[0]), rewrite(cond.between[1])] };
|
|
2648
|
+
if ("beginsWith" in cond)
|
|
2649
|
+
return { beginsWith: rewrite(cond.beginsWith) };
|
|
2650
|
+
return cond;
|
|
2651
|
+
};
|
|
2652
|
+
const bqConfig = {
|
|
2653
|
+
pathBuilder,
|
|
2654
|
+
conditionOps,
|
|
2655
|
+
provide,
|
|
2656
|
+
skFields: orderBy ? [orderBy] : [],
|
|
2657
|
+
composeSkCondition,
|
|
2658
|
+
};
|
|
2659
|
+
return new BoundQueryImpl(q, bqConfig);
|
|
2660
|
+
},
|
|
2300
2661
|
// Query execution
|
|
2301
2662
|
paginate: (q, ...combinators) => {
|
|
2302
2663
|
const final = applyQuery(q, combinators);
|