effect-dynamodb 0.1.0 → 1.1.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 +3 -3
- package/dist/DynamoClient.d.ts.map +1 -1
- package/dist/DynamoClient.js +1 -3
- package/dist/DynamoClient.js.map +1 -1
- package/dist/Entity.d.ts +69 -10
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +383 -2
- package/dist/Entity.js.map +1 -1
- package/dist/KeyComposer.d.ts +14 -0
- package/dist/KeyComposer.d.ts.map +1 -1
- package/dist/KeyComposer.js +22 -1
- package/dist/KeyComposer.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/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/_AggregateCursor.d.ts +0 -44
- package/dist/_AggregateCursor.d.ts.map +0 -1
- package/dist/_AggregateCursor.js +0 -18
- package/dist/_AggregateCursor.js.map +0 -1
- package/dist/_AggregateEdges.d.ts +0 -77
- package/dist/_AggregateEdges.d.ts.map +0 -1
- package/dist/_AggregateEdges.js +0 -41
- package/dist/_AggregateEdges.js.map +0 -1
- package/dist/_AggregateSchemas.d.ts +0 -69
- package/dist/_AggregateSchemas.d.ts.map +0 -1
- package/dist/_AggregateSchemas.js +0 -194
- package/dist/_AggregateSchemas.js.map +0 -1
- package/dist/_AggregateTypes.d.ts +0 -94
- package/dist/_AggregateTypes.d.ts.map +0 -1
- package/dist/_AggregateTypes.js +0 -7
- package/dist/_AggregateTypes.js.map +0 -1
- package/dist/_EntityCombinators.d.ts +0 -155
- package/dist/_EntityCombinators.d.ts.map +0 -1
- package/dist/_EntityCombinators.js +0 -158
- package/dist/_EntityCombinators.js.map +0 -1
- package/dist/_EntityConfig.d.ts +0 -100
- package/dist/_EntityConfig.d.ts.map +0 -1
- package/dist/_EntityConfig.js +0 -7
- package/dist/_EntityConfig.js.map +0 -1
- package/dist/_EntityOps.d.ts +0 -205
- package/dist/_EntityOps.d.ts.map +0 -1
- package/dist/_EntityOps.js +0 -137
- package/dist/_EntityOps.js.map +0 -1
- package/dist/_EntitySchemas.d.ts +0 -57
- package/dist/_EntitySchemas.d.ts.map +0 -1
- package/dist/_EntitySchemas.js +0 -298
- package/dist/_EntitySchemas.js.map +0 -1
- package/dist/_EntityTypes.d.ts +0 -81
- package/dist/_EntityTypes.d.ts.map +0 -1
- package/dist/_EntityTypes.js +0 -7
- package/dist/_EntityTypes.js.map +0 -1
- package/dist/_sparse_check.d.ts +0 -2
- package/dist/_sparse_check.d.ts.map +0 -1
- package/dist/_sparse_check.js +0 -193
- package/dist/_sparse_check.js.map +0 -1
package/dist/Entity.js
CHANGED
|
@@ -100,7 +100,7 @@ const makeImpl = (config) => {
|
|
|
100
100
|
const isSchemaClass = typeof rawModel === "function";
|
|
101
101
|
const modelFields = getFields(rawModel);
|
|
102
102
|
const hasHiddenFields = Object.values(modelFields).some(isHidden);
|
|
103
|
-
const systemFields = resolveSystemFields(config.timestamps, config.versioned);
|
|
103
|
+
const systemFields = resolveSystemFields(config.timestamps, config.versioned, config.timeSeries);
|
|
104
104
|
// ---------------------------------------------------------------------------
|
|
105
105
|
// Validate indexes
|
|
106
106
|
// ---------------------------------------------------------------------------
|
|
@@ -123,6 +123,82 @@ const makeImpl = (config) => {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Validate timeSeries config (EDD-9010..9016)
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
if (config.timeSeries !== undefined && config.timeSeries !== null) {
|
|
130
|
+
const ts = config.timeSeries;
|
|
131
|
+
const orderBy = ts.orderBy;
|
|
132
|
+
// EDD-9010: orderBy must name a model field
|
|
133
|
+
if (!(orderBy in modelFields)) {
|
|
134
|
+
throw new Error(`[EDD-9010] Entity "${config.entityType}": timeSeries.orderBy "${orderBy}" does not name a model field. ` +
|
|
135
|
+
`Valid model fields: ${Object.keys(modelFields).sort().join(", ")}`);
|
|
136
|
+
}
|
|
137
|
+
// EDD-9011: orderBy must not be a primary-key composite (PK or SK)
|
|
138
|
+
const primary = config.indexes.primary;
|
|
139
|
+
const pkSkComposites = new Set([...primary.pk.composite, ...primary.sk.composite]);
|
|
140
|
+
if (pkSkComposites.has(orderBy)) {
|
|
141
|
+
throw new Error(`[EDD-9011] Entity "${config.entityType}": timeSeries.orderBy "${orderBy}" must not appear ` +
|
|
142
|
+
`in the primary key pk or sk composite — it shadows the #e# event-SK infix.`);
|
|
143
|
+
}
|
|
144
|
+
// EDD-9012: mutually exclusive with versioned
|
|
145
|
+
if (config.versioned !== undefined && config.versioned !== null && config.versioned !== false) {
|
|
146
|
+
throw new Error(`[EDD-9012] Entity "${config.entityType}": timeSeries and versioned are mutually exclusive. ` +
|
|
147
|
+
`Pick one consistency model per entity.`);
|
|
148
|
+
}
|
|
149
|
+
// EDD-9015: mutually exclusive with softDelete
|
|
150
|
+
if (config.softDelete !== undefined &&
|
|
151
|
+
config.softDelete !== null &&
|
|
152
|
+
config.softDelete !== false) {
|
|
153
|
+
throw new Error(`[EDD-9015] Entity "${config.entityType}": timeSeries and softDelete are mutually exclusive. ` +
|
|
154
|
+
`Append on a soft-deleted item would land on a new empty row — not a sound resurrection model.`);
|
|
155
|
+
}
|
|
156
|
+
// EDD-9016: appendInput is required
|
|
157
|
+
if (ts.appendInput === undefined || ts.appendInput === null) {
|
|
158
|
+
throw new Error(`[EDD-9016] Entity "${config.entityType}": timeSeries.appendInput is required. ` +
|
|
159
|
+
`Define a Schema.Struct whose fields are the subset of the model allowed in .append() input. ` +
|
|
160
|
+
`Fields outside appendInput are preserved on the current item — this is the enrichment-preservation guarantee. ` +
|
|
161
|
+
`To opt out (dangerous), pass the full model schema explicitly.`);
|
|
162
|
+
}
|
|
163
|
+
const appendInputFields = (() => {
|
|
164
|
+
const ai = ts.appendInput;
|
|
165
|
+
if ("fields" in ai && typeof ai.fields === "object") {
|
|
166
|
+
return Object.keys(ai.fields);
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`[EDD-9016] Entity "${config.entityType}": timeSeries.appendInput must be a Schema.Struct or Schema.Class (.fields required).`);
|
|
169
|
+
})();
|
|
170
|
+
const appendInputFieldSet = new Set(appendInputFields);
|
|
171
|
+
// EDD-9013: appendInput must include orderBy + all PK/SK composites
|
|
172
|
+
if (!appendInputFieldSet.has(orderBy)) {
|
|
173
|
+
throw new Error(`[EDD-9013] Entity "${config.entityType}": timeSeries.appendInput must include orderBy "${orderBy}". ` +
|
|
174
|
+
`Without it .append() cannot evaluate the CAS condition.`);
|
|
175
|
+
}
|
|
176
|
+
for (const composite of pkSkComposites) {
|
|
177
|
+
if (!appendInputFieldSet.has(composite)) {
|
|
178
|
+
throw new Error(`[EDD-9013] Entity "${config.entityType}": timeSeries.appendInput missing primary-key composite "${composite}". ` +
|
|
179
|
+
`Every PK/SK composite must appear in appendInput so the event can be addressed.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// EDD-9014: orderBy must not name a ref field or ref-derived ${name}Id field
|
|
183
|
+
for (const fieldName of Object.keys(modelFields)) {
|
|
184
|
+
if (isRefField(fieldName, config.model)) {
|
|
185
|
+
if (orderBy === fieldName || orderBy === `${fieldName}Id`) {
|
|
186
|
+
throw new Error(`[EDD-9014] Entity "${config.entityType}": timeSeries.orderBy "${orderBy}" names a ref ` +
|
|
187
|
+
`or ref-derived id field. Refs are create-time denormalisations and cannot serve as the event clock.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Also: appendInput must not declare any ref-derived ${name}Id field —
|
|
192
|
+
// ref changes go through .update(), not .append() (§4.6).
|
|
193
|
+
for (const fieldName of Object.keys(modelFields)) {
|
|
194
|
+
if (isRefField(fieldName, config.model)) {
|
|
195
|
+
if (appendInputFieldSet.has(`${fieldName}Id`)) {
|
|
196
|
+
throw new Error(`[EDD-9014] Entity "${config.entityType}": timeSeries.appendInput must not include ref-derived ` +
|
|
197
|
+
`"${fieldName}Id" — refs cannot be reassigned via .append(). Use .update() to change a ref.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
126
202
|
const resolvedRefs = config.refs
|
|
127
203
|
? Object.entries(config.refs).map(([fieldName, refValue]) => {
|
|
128
204
|
// Extract entity from shorthand or expanded form
|
|
@@ -182,7 +258,7 @@ const makeImpl = (config) => {
|
|
|
182
258
|
}
|
|
183
259
|
// Resolve identifier field name from model annotation
|
|
184
260
|
const resolvedIdentifier = getIdentifierField(config.model)?.name;
|
|
185
|
-
const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier);
|
|
261
|
+
const schemas = buildDerivedSchemas(modelFields, allIndexes, systemFields, resolvedRefs, immutableFields, resolvedIdentifier, config.timeSeries);
|
|
186
262
|
// schema and tableTag are injected via _configure() when the entity is registered
|
|
187
263
|
// on a Table and bound through DynamoClient.make(). They are captured by operation
|
|
188
264
|
// closures and resolved at runtime (inside Effects), not at definition time.
|
|
@@ -1727,6 +1803,261 @@ const makeImpl = (config) => {
|
|
|
1727
1803
|
return yield* decodeAs(raw, result.Attributes, mode);
|
|
1728
1804
|
}), self, input);
|
|
1729
1805
|
// ---------------------------------------------------------------------------
|
|
1806
|
+
// append operation — time-series primitive (only when `timeSeries` configured)
|
|
1807
|
+
//
|
|
1808
|
+
// Two-item `TransactWriteItems`:
|
|
1809
|
+
// - UpdateItem on current: scoped SET (appendInput fields only) + CAS on
|
|
1810
|
+
// `orderBy`, with GSI keys recomposed when any appendInput field is a
|
|
1811
|
+
// GSI composite. `createdAt` uses if_not_exists on first append.
|
|
1812
|
+
// - Put of event: full decoded input + __edd_e__ + _ttl (if configured),
|
|
1813
|
+
// GSI keys stripped, SK replaced with `<currentSk>#e#<orderByValue>`.
|
|
1814
|
+
//
|
|
1815
|
+
// On TransactionCancelled or ConditionalCheckFailed, we issue a follow-up
|
|
1816
|
+
// GetItem on the primary key and return `{ applied: false, reason: "stale",
|
|
1817
|
+
// current }`. On success, same follow-up GetItem yields the post-append
|
|
1818
|
+
// current. See `docs/designs/timeseries.md` §4.
|
|
1819
|
+
// ---------------------------------------------------------------------------
|
|
1820
|
+
const timeSeriesConfig = config.timeSeries;
|
|
1821
|
+
const append = (input, userCondition) => Effect.gen(function* () {
|
|
1822
|
+
if (!timeSeriesConfig) {
|
|
1823
|
+
return yield* new ValidationError({
|
|
1824
|
+
entityType,
|
|
1825
|
+
operation: "append",
|
|
1826
|
+
cause: "Entity is not configured with timeSeries. .append() requires timeSeries config.",
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
const client = yield* DynamoClient;
|
|
1830
|
+
const { name: tableName } = yield* tableTag;
|
|
1831
|
+
const orderByField = timeSeriesConfig.orderBy;
|
|
1832
|
+
const ttlDuration = timeSeriesConfig.ttl;
|
|
1833
|
+
const appendInputSchema = schemas.appendInputSchema;
|
|
1834
|
+
// Decode input via appendInputSchema
|
|
1835
|
+
const decodedInput = yield* Schema.decodeUnknownEffect(appendInputSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
|
|
1836
|
+
entityType,
|
|
1837
|
+
operation: "append.decode",
|
|
1838
|
+
cause,
|
|
1839
|
+
})));
|
|
1840
|
+
const decoded = decodedInput;
|
|
1841
|
+
// Compose current-item primary key (pk + sk derived from PK/SK composites in decoded)
|
|
1842
|
+
const primary = config.indexes.primary;
|
|
1843
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, decoded);
|
|
1844
|
+
const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decoded);
|
|
1845
|
+
const marshalledKey = toAttributeMap({
|
|
1846
|
+
[primary.pk.field]: pkValue,
|
|
1847
|
+
[primary.sk.field]: currentSk,
|
|
1848
|
+
});
|
|
1849
|
+
// Prepare domain-serialised values for the item build.
|
|
1850
|
+
// Build a shallow copy and apply date serialization (per fieldEncodings)
|
|
1851
|
+
// before marshalling. This mirrors the put path.
|
|
1852
|
+
const serialisedInput = { ...decoded };
|
|
1853
|
+
serializeDateFields(serialisedInput);
|
|
1854
|
+
// ---------- Build UpdateItem (scoped SET + CAS) ----------
|
|
1855
|
+
const setClauses = [];
|
|
1856
|
+
const names = {};
|
|
1857
|
+
const values = {};
|
|
1858
|
+
let counter = 0;
|
|
1859
|
+
// Only fields named in appendInput (the serialisedInput object). This is
|
|
1860
|
+
// the enrichment-preservation contract — fields outside appendInput are
|
|
1861
|
+
// never touched. PK composites ARE included: they're stored as regular
|
|
1862
|
+
// attributes on the item (mirrors `.put()`), and though their values
|
|
1863
|
+
// never change, writing them on every append makes the first append
|
|
1864
|
+
// (where the row doesn't yet exist) materialise the row correctly.
|
|
1865
|
+
const pkCompositeSet = new Set(primaryKeyComposites(config.indexes));
|
|
1866
|
+
for (const [attr, val] of Object.entries(serialisedInput)) {
|
|
1867
|
+
if (val === undefined)
|
|
1868
|
+
continue;
|
|
1869
|
+
const nameKey = `#a${counter}`;
|
|
1870
|
+
const valKey = `:a${counter}`;
|
|
1871
|
+
names[nameKey] = resolveDbName(attr);
|
|
1872
|
+
values[valKey] = toAttributeValue(val);
|
|
1873
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1874
|
+
counter++;
|
|
1875
|
+
}
|
|
1876
|
+
// GSI key recomposition: any GSI whose composites are touched by input.
|
|
1877
|
+
// Reuses the same machinery as `.update()`. PK composites are excluded
|
|
1878
|
+
// from the update payload: they're already in the key and never change
|
|
1879
|
+
// during an append. Non-PK composites that overlap between appendInput
|
|
1880
|
+
// and a GSI trigger recomposition, which requires the full GSI composite
|
|
1881
|
+
// set (partial presence raises `PartialGsiCompositeError`).
|
|
1882
|
+
const nonPkAppendFields = {};
|
|
1883
|
+
for (const [attr, val] of Object.entries(decoded)) {
|
|
1884
|
+
if (!pkCompositeSet.has(attr)) {
|
|
1885
|
+
nonPkAppendFields[attr] = val;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
let gsiKeys;
|
|
1889
|
+
try {
|
|
1890
|
+
gsiKeys = KeyComposer.composeGsiKeysForUpdate(schema, entityType, entityVersion, allIndexes, nonPkAppendFields, decoded);
|
|
1891
|
+
}
|
|
1892
|
+
catch (e) {
|
|
1893
|
+
return yield* new ValidationError({
|
|
1894
|
+
entityType,
|
|
1895
|
+
operation: "append.gsiComposites",
|
|
1896
|
+
cause: e instanceof Error ? e.message : e,
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
for (const [field, value] of Object.entries(gsiKeys)) {
|
|
1900
|
+
const nameKey = `#a${counter}`;
|
|
1901
|
+
const valKey = `:a${counter}`;
|
|
1902
|
+
names[nameKey] = field;
|
|
1903
|
+
values[valKey] = toAttributeValue(value);
|
|
1904
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1905
|
+
counter++;
|
|
1906
|
+
}
|
|
1907
|
+
// Entity type discriminator — idempotent; ensures existing items
|
|
1908
|
+
// without __edd_e__ still get tagged on first append.
|
|
1909
|
+
{
|
|
1910
|
+
const nameKey = `#a${counter}`;
|
|
1911
|
+
const valKey = `:a${counter}`;
|
|
1912
|
+
names[nameKey] = "__edd_e__";
|
|
1913
|
+
values[valKey] = toAttributeValue(entityType);
|
|
1914
|
+
setClauses.push(`${nameKey} = ${valKey}`);
|
|
1915
|
+
counter++;
|
|
1916
|
+
}
|
|
1917
|
+
// createdAt (if configured) — if_not_exists so subsequent appends leave it alone
|
|
1918
|
+
if (systemFields.createdAt) {
|
|
1919
|
+
const nameKey = `#a${counter}`;
|
|
1920
|
+
const valKey = `:a${counter}`;
|
|
1921
|
+
names[nameKey] = systemFields.createdAt;
|
|
1922
|
+
values[valKey] = toAttributeValue(generateTimestamp(systemFields.createdAtEncoding));
|
|
1923
|
+
setClauses.push(`${nameKey} = if_not_exists(${nameKey}, ${valKey})`);
|
|
1924
|
+
counter++;
|
|
1925
|
+
}
|
|
1926
|
+
// CAS condition: attribute_not_exists(#pk) OR #ob < :newOb
|
|
1927
|
+
names["#_tspk"] = primary.pk.field;
|
|
1928
|
+
names["#_tsob"] = resolveDbName(orderByField);
|
|
1929
|
+
// The comparison uses the stored domain-value representation — for
|
|
1930
|
+
// DateTime.Utc this is ISO (lexicographic == chronological), for numbers
|
|
1931
|
+
// it's numeric, for strings it's lexicographic. Matches how the current
|
|
1932
|
+
// item stores `orderByField`.
|
|
1933
|
+
const newObValue = serialisedInput[orderByField];
|
|
1934
|
+
values[":_tsNewOb"] = toAttributeValue(newObValue);
|
|
1935
|
+
const casCondition = "attribute_not_exists(#_tspk) OR #_tsob < :_tsNewOb";
|
|
1936
|
+
let finalCondition = casCondition;
|
|
1937
|
+
if (userCondition !== undefined) {
|
|
1938
|
+
const uc = compileCondition(userCondition, resolveDbName);
|
|
1939
|
+
finalCondition = `(${casCondition}) AND (${uc.expression})`;
|
|
1940
|
+
Object.assign(names, uc.names);
|
|
1941
|
+
Object.assign(values, uc.values);
|
|
1942
|
+
}
|
|
1943
|
+
const updateExpression = `SET ${setClauses.join(", ")}`;
|
|
1944
|
+
// ---------- Build Put of event item ----------
|
|
1945
|
+
const eventSk = KeyComposer.composeEventSk(currentSk, newObValue, schema.casing);
|
|
1946
|
+
const eventItem = { ...decoded };
|
|
1947
|
+
// Serialise date fields to storage format for the Put
|
|
1948
|
+
serializeDateFields(eventItem);
|
|
1949
|
+
// Rename domain → DB names for the Put (matches put path)
|
|
1950
|
+
renameToDynamo(eventItem);
|
|
1951
|
+
// pk + sk (event)
|
|
1952
|
+
eventItem[primary.pk.field] = pkValue;
|
|
1953
|
+
eventItem[primary.sk.field] = eventSk;
|
|
1954
|
+
// __edd_e__
|
|
1955
|
+
eventItem.__edd_e__ = entityType;
|
|
1956
|
+
// _ttl
|
|
1957
|
+
if (ttlDuration) {
|
|
1958
|
+
eventItem._ttl = Math.floor(Date.now() / 1000) + Duration.toSeconds(ttlDuration);
|
|
1959
|
+
}
|
|
1960
|
+
// Events never participate in indexes: strip any GSI key fields that the
|
|
1961
|
+
// naive spread above may have carried over. gsiKeys weren't written into
|
|
1962
|
+
// eventItem (only decoded fields are), but defensively clear them.
|
|
1963
|
+
for (const field of gsiKeyFields()) {
|
|
1964
|
+
delete eventItem[field];
|
|
1965
|
+
}
|
|
1966
|
+
const marshalledEventItem = toAttributeMap(eventItem);
|
|
1967
|
+
// ---------- Execute TransactWriteItems ----------
|
|
1968
|
+
const transactResult = yield* client
|
|
1969
|
+
.transactWriteItems({
|
|
1970
|
+
TransactItems: [
|
|
1971
|
+
{
|
|
1972
|
+
Update: {
|
|
1973
|
+
TableName: tableName,
|
|
1974
|
+
Key: marshalledKey,
|
|
1975
|
+
UpdateExpression: updateExpression,
|
|
1976
|
+
ConditionExpression: finalCondition,
|
|
1977
|
+
ExpressionAttributeNames: names,
|
|
1978
|
+
ExpressionAttributeValues: values,
|
|
1979
|
+
},
|
|
1980
|
+
},
|
|
1981
|
+
{
|
|
1982
|
+
Put: {
|
|
1983
|
+
TableName: tableName,
|
|
1984
|
+
Item: marshalledEventItem,
|
|
1985
|
+
},
|
|
1986
|
+
},
|
|
1987
|
+
],
|
|
1988
|
+
})
|
|
1989
|
+
.pipe(Effect.matchEffect({
|
|
1990
|
+
onSuccess: () => Effect.succeed({ stale: false }),
|
|
1991
|
+
onFailure: (err) => {
|
|
1992
|
+
// TransactionCancelled or ConditionalCheckFailed at the transaction
|
|
1993
|
+
// level both map to "stale" (can't distinguish CAS from user-cond
|
|
1994
|
+
// in v1 — see §12 decision 5).
|
|
1995
|
+
if (isAwsTransactionCancelled(err.cause) || isAwsConditionalCheckFailed(err.cause)) {
|
|
1996
|
+
return Effect.succeed({ stale: true });
|
|
1997
|
+
}
|
|
1998
|
+
return Effect.fail(err);
|
|
1999
|
+
},
|
|
2000
|
+
}));
|
|
2001
|
+
// Follow-up GetItem to read the current (post-append on success, winning
|
|
2002
|
+
// state on stale). One read is required either way — the contract
|
|
2003
|
+
// returns `Model` which demands the full row including enrichment.
|
|
2004
|
+
const followUp = yield* client.getItem({
|
|
2005
|
+
TableName: tableName,
|
|
2006
|
+
Key: marshalledKey,
|
|
2007
|
+
});
|
|
2008
|
+
if (!followUp.Item) {
|
|
2009
|
+
// TTL race or out-of-band delete — surface as a DynamoClientError
|
|
2010
|
+
// (the previous state vanished under us). This is NOT a stale result.
|
|
2011
|
+
return yield* Effect.fail(new ValidationError({
|
|
2012
|
+
entityType,
|
|
2013
|
+
operation: "append.followUp",
|
|
2014
|
+
cause: "Current item not found after append — possible TTL race or concurrent delete.",
|
|
2015
|
+
}));
|
|
2016
|
+
}
|
|
2017
|
+
const rawCurrent = fromAttributeMap(followUp.Item);
|
|
2018
|
+
const currentModel = yield* decodeAs(rawCurrent, followUp.Item, "model");
|
|
2019
|
+
if (transactResult.stale) {
|
|
2020
|
+
return {
|
|
2021
|
+
applied: false,
|
|
2022
|
+
reason: "stale",
|
|
2023
|
+
current: currentModel,
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
return { applied: true, current: currentModel };
|
|
2027
|
+
});
|
|
2028
|
+
// ---------------------------------------------------------------------------
|
|
2029
|
+
// history operation — BoundQuery for event items only
|
|
2030
|
+
// ---------------------------------------------------------------------------
|
|
2031
|
+
const history = (key) => {
|
|
2032
|
+
if (!timeSeriesConfig) {
|
|
2033
|
+
throw new Error(`[EDD-9010] Entity "${entityType}": .history() requires timeSeries config on the entity.`);
|
|
2034
|
+
}
|
|
2035
|
+
const decodedKey = Schema.decodeUnknownSync(schemas.keySchema)(key);
|
|
2036
|
+
const primary = config.indexes.primary;
|
|
2037
|
+
const pkValue = KeyComposer.composePk(schema, entityType, primary, decodedKey);
|
|
2038
|
+
const currentSk = KeyComposer.composeSk(schema, entityType, entityVersion, primary, decodedKey);
|
|
2039
|
+
const prefix = KeyComposer.composeEventSkPrefix(currentSk, schema.casing);
|
|
2040
|
+
const decodeHistory = (raw) => {
|
|
2041
|
+
renameFromDynamo(raw);
|
|
2042
|
+
deserializeDateFields(raw);
|
|
2043
|
+
return Schema.decodeUnknownEffect(schemas.historyRecordSchema)(raw).pipe(Effect.map(attachPrototype), Effect.mapError((cause) => new ValidationError({
|
|
2044
|
+
entityType,
|
|
2045
|
+
operation: "history.decode",
|
|
2046
|
+
cause,
|
|
2047
|
+
})));
|
|
2048
|
+
};
|
|
2049
|
+
return Query.make({
|
|
2050
|
+
tableName: "",
|
|
2051
|
+
indexName: undefined,
|
|
2052
|
+
pkField: primary.pk.field,
|
|
2053
|
+
pkValue,
|
|
2054
|
+
skField: primary.sk.field,
|
|
2055
|
+
entityTypes: [entityType],
|
|
2056
|
+
decoder: (raw) => decodeHistory(raw),
|
|
2057
|
+
resolveTableName: tableTag.useSync((tc) => tc.name),
|
|
2058
|
+
}).pipe(Query.where({ beginsWith: prefix }));
|
|
2059
|
+
};
|
|
2060
|
+
// ---------------------------------------------------------------------------
|
|
1730
2061
|
// query namespace
|
|
1731
2062
|
// ---------------------------------------------------------------------------
|
|
1732
2063
|
const queryNamespace = {};
|
|
@@ -2170,6 +2501,7 @@ const makeImpl = (config) => {
|
|
|
2170
2501
|
softDelete: config.softDelete,
|
|
2171
2502
|
unique: config.unique,
|
|
2172
2503
|
identifier: resolvedIdentifier,
|
|
2504
|
+
timeSeries: config.timeSeries,
|
|
2173
2505
|
_resolvedRefs: resolvedRefs,
|
|
2174
2506
|
/** @internal Full decode pipeline: rename + date deser + schema decode. Used by Batch/Aggregate. */
|
|
2175
2507
|
_decodeRecord: decodeRecord,
|
|
@@ -2206,6 +2538,8 @@ const makeImpl = (config) => {
|
|
|
2206
2538
|
restore,
|
|
2207
2539
|
purge,
|
|
2208
2540
|
deleted: { get: deletedGet, list: deletedList },
|
|
2541
|
+
append,
|
|
2542
|
+
history,
|
|
2209
2543
|
set,
|
|
2210
2544
|
expectedVersion,
|
|
2211
2545
|
query: queryNamespace,
|
|
@@ -2297,6 +2631,53 @@ export const bind = (entity) => Effect.gen(function* () {
|
|
|
2297
2631
|
get: (key) => provide(entity.deleted.get(key)._run("record")),
|
|
2298
2632
|
list: (key) => wrapAsBoundQuery(entity.deleted.list(key)),
|
|
2299
2633
|
},
|
|
2634
|
+
// Time-series (no-ops when entity is not configured; runtime check in
|
|
2635
|
+
// the entity-level `append` returns a ValidationError).
|
|
2636
|
+
append: (input, condition) => provide(entity.append(input, condition)),
|
|
2637
|
+
history: (key) => {
|
|
2638
|
+
const q = entity.history(key);
|
|
2639
|
+
const pathBuilder = createPathBuilder();
|
|
2640
|
+
const conditionOps = createConditionOps();
|
|
2641
|
+
const ts = entity.timeSeries;
|
|
2642
|
+
const orderBy = ts?.orderBy;
|
|
2643
|
+
const entityInternals = entity;
|
|
2644
|
+
const schemaRef = entityInternals._schema;
|
|
2645
|
+
const entityTypeRef = entityInternals.entityType;
|
|
2646
|
+
// composeSkCondition: rewrite user's .where() values by prefixing
|
|
2647
|
+
// with `<currentSk>#e#` and applying serialisation + casing to the
|
|
2648
|
+
// user-supplied orderBy value.
|
|
2649
|
+
const primary = entity.indexes.primary;
|
|
2650
|
+
const composeSkCondition = (cond) => {
|
|
2651
|
+
// We need the `currentSk` + prefix. The user's `key` lets us derive
|
|
2652
|
+
// `currentSk` via the same primary SK composer used by `history()`.
|
|
2653
|
+
const currentSk = KeyComposer.composeSk(schemaRef, entityTypeRef, 1, primary, key);
|
|
2654
|
+
const prefix = KeyComposer.composeEventSkPrefix(currentSk, schemaRef.casing);
|
|
2655
|
+
const rewrite = (v) => `${prefix}${DynamoSchema.applyCasing(KeyComposer.serializeValue(v), schemaRef.casing)}`;
|
|
2656
|
+
if ("eq" in cond)
|
|
2657
|
+
return { eq: rewrite(cond.eq) };
|
|
2658
|
+
if ("lt" in cond)
|
|
2659
|
+
return { lt: rewrite(cond.lt) };
|
|
2660
|
+
if ("lte" in cond)
|
|
2661
|
+
return { lte: rewrite(cond.lte) };
|
|
2662
|
+
if ("gt" in cond)
|
|
2663
|
+
return { gt: rewrite(cond.gt) };
|
|
2664
|
+
if ("gte" in cond)
|
|
2665
|
+
return { gte: rewrite(cond.gte) };
|
|
2666
|
+
if ("between" in cond)
|
|
2667
|
+
return { between: [rewrite(cond.between[0]), rewrite(cond.between[1])] };
|
|
2668
|
+
if ("beginsWith" in cond)
|
|
2669
|
+
return { beginsWith: rewrite(cond.beginsWith) };
|
|
2670
|
+
return cond;
|
|
2671
|
+
};
|
|
2672
|
+
const bqConfig = {
|
|
2673
|
+
pathBuilder,
|
|
2674
|
+
conditionOps,
|
|
2675
|
+
provide,
|
|
2676
|
+
skFields: orderBy ? [orderBy] : [],
|
|
2677
|
+
composeSkCondition,
|
|
2678
|
+
};
|
|
2679
|
+
return new BoundQueryImpl(q, bqConfig);
|
|
2680
|
+
},
|
|
2300
2681
|
// Query execution
|
|
2301
2682
|
paginate: (q, ...combinators) => {
|
|
2302
2683
|
const final = applyQuery(q, combinators);
|