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/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
- // Cascade REMOVE to GSI key fields: when a removed attribute is a GSI
1232
- // composite, the GSI pk/sk fields must also be removed so the item drops
1233
- // out of the sparse index.
1234
- if (removedSet) {
1235
- for (const [, indexDef] of Object.entries(allIndexes)) {
1236
- if (!("index" in indexDef))
1237
- continue;
1238
- const gsiComposites = [...indexDef.pk.composite, ...indexDef.sk.composite].filter((attr) => !pkCompositeSet.has(attr));
1239
- if (gsiComposites.some((attr) => removedSet.has(attr))) {
1240
- for (const keyField of [indexDef.pk.field, indexDef.sk.field]) {
1241
- const nameKey = `#r${removeClauses.length}`;
1242
- names[nameKey] = keyField;
1243
- removeClauses.push(nameKey);
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
- // Helper: apply combinators to an EntityUpdate, then run in record mode + provide
2242
- const applyUpdate = (op, combinators) => {
2243
- let result = op;
2244
- for (const fn of combinators)
2245
- result = fn(result);
2246
- return provide(result._run("record"));
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 — all ops return Effect (auto-wrapped)
2596
+ // CRUD — fluent bound builders (yieldable, no .run() terminal)
2283
2597
  get: (key) => provide(entity.get(key)._run("record")),
2284
- put: (input, ...combinators) => applyPut(entity.put(input), combinators),
2285
- create: (input, ...combinators) => applyPut(entity.create(input), combinators),
2286
- update: (key, ...combinators) => applyUpdate(entity.update(key), combinators),
2287
- delete: (key, ...combinators) => applyDelete(entity.delete(key), combinators),
2288
- upsert: (input, ...combinators) => applyPut(entity.upsert(input), combinators),
2289
- patch: (key, ...combinators) => applyUpdate(entity.patch(key), combinators),
2290
- deleteIfExists: (key, ...combinators) => applyDelete(entity.deleteIfExists(key), combinators),
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);