effect-dynamodb 1.1.0 → 1.3.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
@@ -6,11 +6,12 @@
6
6
  * carries derived schemas for 7 type extractors (Model, Record, Input, Update,
7
7
  * Key, Item, Marshalled).
8
8
  */
9
- import { Duration, Effect, Schema, Stream } from "effect";
9
+ import { DateTime, Duration, Effect, Schema, Stream } from "effect";
10
10
  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,20 @@ 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, config.timeSeries);
104
+ const systemFields = resolveSystemFields(config.timestamps, config.versioned, config.timeSeries, modelFields, configuredAttributes);
105
+ // System-field collision policy:
106
+ // - Timestamp collisions with a non-date model field silently yield the
107
+ // field to the user (see `resolveSystemFields`).
108
+ // - `version` collision is allowed. The field is stripped from
109
+ // `inputSchema` / `createSchema` / `updateSchema` (see
110
+ // `buildDerivedSchemas`), so user code cannot supply it via `put` /
111
+ // `create` / `.set()`. Library continues to own the write path
112
+ // (auto-increment + `.expectedVersion()` optimistic locking). The model
113
+ // declaration is purely for type-level visibility — callers can
114
+ // construct Schema.Class instances and read `version` off the record.
115
+ // - Manual version fixups remain available by dropping to the raw
116
+ // `DynamoClient` service; they deliberately do not appear on the
117
+ // `Entity` surface to keep the locking invariant visibly inviolable.
104
118
  // ---------------------------------------------------------------------------
105
119
  // Validate indexes
106
120
  // ---------------------------------------------------------------------------
@@ -415,6 +429,10 @@ const makeImpl = (config) => {
415
429
  /**
416
430
  * Generate a timestamp value appropriate for the field's encoding.
417
431
  * Default (no encoding): ISO string. Custom encoding: serialized primitive.
432
+ *
433
+ * Used for non-colliding system fields (field not declared in model) — the
434
+ * value is written straight into the DynamoDB item and is NOT routed through
435
+ * `serializeDateFields` (because the field is absent from `fieldEncodings`).
418
436
  */
419
437
  const generateTimestamp = (encoding) => {
420
438
  if (!encoding)
@@ -422,6 +440,22 @@ const makeImpl = (config) => {
422
440
  const now = new Date();
423
441
  return serializeDateForDynamo(now, encoding);
424
442
  };
443
+ /**
444
+ * Generate a timestamp as a domain value (DateTime.Utc / DateTime.Zoned / Date),
445
+ * matching the field's declared domain. Used for system fields that COLLIDE
446
+ * with a model-declared field — the value flows through `serializeDateFields`
447
+ * (which requires a domain value) alongside any user-supplied override.
448
+ */
449
+ const generateDomainTimestamp = (encoding) => {
450
+ switch (encoding.domain) {
451
+ case "DateTime.Utc":
452
+ return DateTime.makeUnsafe(new Date());
453
+ case "DateTime.Zoned":
454
+ return DateTime.makeZonedUnsafe(new Date(), { timeZone: "UTC" });
455
+ case "Date":
456
+ return new Date();
457
+ }
458
+ };
425
459
  // ---------------------------------------------------------------------------
426
460
  // Lifecycle config helpers
427
461
  // ---------------------------------------------------------------------------
@@ -710,7 +744,7 @@ const makeImpl = (config) => {
710
744
  const client = yield* DynamoClient;
711
745
  const { name: tableName } = yield* tableTag;
712
746
  // Decode input
713
- const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
747
+ const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputDecodeSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
714
748
  entityType,
715
749
  operation: "put.decode",
716
750
  cause,
@@ -723,11 +757,29 @@ const makeImpl = (config) => {
723
757
  : decodedInput;
724
758
  // Build the DynamoDB item
725
759
  const item = { ...decoded };
726
- // Add system fields (encoding-aware timestamps)
727
- if (systemFields.createdAt)
728
- item[systemFields.createdAt] = generateTimestamp(systemFields.createdAtEncoding);
729
- if (systemFields.updatedAt)
730
- item[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding);
760
+ // Add system fields. When a timestamp field collides with a
761
+ // model-declared field, the user may have supplied their own value
762
+ // (see `inputDecodeSchema` — optional on collision). Respect their
763
+ // value if present; otherwise generate. In the collision path the
764
+ // value is a DOMAIN value (DateTime.Utc / Date / ...) so it flows
765
+ // through `serializeDateFields` downstream. Non-colliding fields
766
+ // get a storage primitive directly (no `serializeDateFields` pass).
767
+ if (systemFields.createdAt) {
768
+ if (item[systemFields.createdAt] === undefined) {
769
+ item[systemFields.createdAt] =
770
+ systemFields.createdAtCollision && systemFields.createdAtEncoding
771
+ ? generateDomainTimestamp(systemFields.createdAtEncoding)
772
+ : generateTimestamp(systemFields.createdAtEncoding);
773
+ }
774
+ }
775
+ if (systemFields.updatedAt) {
776
+ if (item[systemFields.updatedAt] === undefined) {
777
+ item[systemFields.updatedAt] =
778
+ systemFields.updatedAtCollision && systemFields.updatedAtEncoding
779
+ ? generateDomainTimestamp(systemFields.updatedAtEncoding)
780
+ : generateTimestamp(systemFields.updatedAtEncoding);
781
+ }
782
+ }
731
783
  if (systemFields.version)
732
784
  item[systemFields.version] = 1;
733
785
  // Add entity type discriminator
@@ -935,7 +987,7 @@ const makeImpl = (config) => {
935
987
  cause,
936
988
  })));
937
989
  // Decode updates
938
- const decodedUpdates = yield* Schema.decodeUnknownEffect(schemas.updateSchema)(updates ?? {}).pipe(Effect.mapError((cause) => new ValidationError({
990
+ const decodedUpdates = yield* Schema.decodeUnknownEffect(schemas.updateDecodeSchema)(updates ?? {}).pipe(Effect.mapError((cause) => new ValidationError({
939
991
  entityType,
940
992
  operation: "update.decodeUpdates",
941
993
  cause,
@@ -1031,12 +1083,19 @@ const makeImpl = (config) => {
1031
1083
  }
1032
1084
  }
1033
1085
  }
1034
- // Increment version and update timestamp
1086
+ // Increment version and update timestamp. If the caller supplied a
1087
+ // value for `updatedAt` (allowed when the field collides with a
1088
+ // model-declared field), respect it; else generate.
1035
1089
  const newVersion = currentVersion + 1;
1036
1090
  if (systemFields.version)
1037
1091
  newItem[systemFields.version] = newVersion;
1038
- if (systemFields.updatedAt)
1039
- newItem[systemFields.updatedAt] = generateTimestamp(systemFields.updatedAtEncoding);
1092
+ if (systemFields.updatedAt) {
1093
+ const userSupplied = hydratedUpdates[systemFields.updatedAt];
1094
+ newItem[systemFields.updatedAt] =
1095
+ userSupplied !== undefined
1096
+ ? userSupplied
1097
+ : generateTimestamp(systemFields.updatedAtEncoding);
1098
+ }
1040
1099
  // Convert DynamoDB attribute names to domain names for key composition
1041
1100
  // and sentinel value comparison (newItem was built from currentRaw which
1042
1101
  // uses DynamoDB names; composites and unique fields use domain names)
@@ -1202,10 +1261,22 @@ const makeImpl = (config) => {
1202
1261
  const names = {};
1203
1262
  const values = {};
1204
1263
  let counter = 0;
1264
+ // Serialize any domain-value date fields in updates to storage primitives.
1265
+ // Without this, a `.set({ occurredAt: DateTime.Utc })` would marshal as a
1266
+ // class-instance map.
1267
+ const serializedUpdates = { ...hydratedUpdates };
1268
+ serializeDateFields(serializedUpdates);
1269
+ // System-colliding updatedAt is handled by the system-field block below.
1270
+ // createdAt and version are already excluded by `updateDecodeSchema`.
1271
+ const updateSystemColliders = new Set();
1272
+ if (systemFields.updatedAtCollision && systemFields.updatedAt)
1273
+ updateSystemColliders.add(systemFields.updatedAt);
1205
1274
  // Add user-provided updates
1206
- for (const [attr, val] of Object.entries(hydratedUpdates)) {
1275
+ for (const [attr, val] of Object.entries(serializedUpdates)) {
1207
1276
  if (val === undefined)
1208
1277
  continue;
1278
+ if (updateSystemColliders.has(attr))
1279
+ continue;
1209
1280
  const nameKey = `#u${counter}`;
1210
1281
  const valKey = `:u${counter}`;
1211
1282
  names[nameKey] = resolveDbName(attr);
@@ -1213,52 +1284,16 @@ const makeImpl = (config) => {
1213
1284
  setClauses.push(`${nameKey} = ${valKey}`);
1214
1285
  counter++;
1215
1286
  }
1216
- // Shared sets for GSI removal detection (used by both compose and REMOVE)
1217
- const removedSet = uState.remove ? new Set(uState.remove) : undefined;
1218
- const pkCompositeSet = new Set(primaryKeyComposites(config.indexes));
1219
- // Compose GSI keys for indexes whose composites are in the update,
1220
- // filtering out any GSI whose composites are targeted by REMOVE
1221
- const indexesForGsiUpdate = removedSet
1222
- ? (() => {
1223
- const filtered = {};
1224
- for (const [indexName, indexDef] of Object.entries(allIndexes)) {
1225
- if (!("index" in indexDef)) {
1226
- filtered[indexName] = indexDef;
1227
- continue;
1228
- }
1229
- const gsiComposites = [...indexDef.pk.composite, ...indexDef.sk.composite].filter((attr) => !pkCompositeSet.has(attr));
1230
- if (!gsiComposites.some((attr) => removedSet.has(attr))) {
1231
- filtered[indexName] = indexDef;
1232
- }
1233
- }
1234
- return filtered;
1235
- })()
1236
- : allIndexes;
1237
- let gsiKeys;
1238
- try {
1239
- gsiKeys = KeyComposer.composeGsiKeysForUpdate(schema, entityType, entityVersion, indexesForGsiUpdate, hydratedUpdates, decodedKey);
1240
- }
1241
- catch (e) {
1242
- return yield* new ValidationError({
1243
- entityType,
1244
- operation: "update.gsiComposites",
1245
- cause: e instanceof Error ? e.message : e,
1246
- });
1247
- }
1248
- for (const [field, value] of Object.entries(gsiKeys)) {
1249
- const nameKey = `#u${counter}`;
1250
- const valKey = `:u${counter}`;
1251
- names[nameKey] = field;
1252
- values[valKey] = toAttributeValue(value);
1253
- setClauses.push(`${nameKey} = ${valKey}`);
1254
- counter++;
1255
- }
1256
- // Add updatedAt timestamp (encoding-aware)
1287
+ // Add updatedAt timestamp. User-supplied value wins (pre-serialized
1288
+ // above); else fall back to a freshly generated storage primitive.
1257
1289
  if (systemFields.updatedAt) {
1290
+ const userSupplied = serializedUpdates[systemFields.updatedAt];
1258
1291
  const nameKey = `#u${counter}`;
1259
1292
  const valKey = `:u${counter}`;
1260
1293
  names[nameKey] = systemFields.updatedAt;
1261
- values[valKey] = toAttributeValue(generateTimestamp(systemFields.updatedAtEncoding));
1294
+ values[valKey] = toAttributeValue(userSupplied !== undefined
1295
+ ? userSupplied
1296
+ : generateTimestamp(systemFields.updatedAtEncoding));
1262
1297
  setClauses.push(`${nameKey} = ${valKey}`);
1263
1298
  counter++;
1264
1299
  }
@@ -1297,6 +1332,7 @@ const makeImpl = (config) => {
1297
1332
  }
1298
1333
  }
1299
1334
  // REMOVE
1335
+ const removedSet = uState.remove ? new Set(uState.remove) : undefined;
1300
1336
  if (uState.remove) {
1301
1337
  for (const attr of uState.remove) {
1302
1338
  const nameKey = `#r${removeClauses.length}`;
@@ -1304,22 +1340,23 @@ const makeImpl = (config) => {
1304
1340
  removeClauses.push(nameKey);
1305
1341
  }
1306
1342
  }
1307
- // Cascade REMOVE to GSI key fields: when a removed attribute is a GSI
1308
- // composite, the GSI pk/sk fields must also be removed so the item drops
1309
- // out of the sparse index.
1310
- if (removedSet) {
1311
- for (const [, indexDef] of Object.entries(allIndexes)) {
1312
- if (!("index" in indexDef))
1313
- continue;
1314
- const gsiComposites = [...indexDef.pk.composite, ...indexDef.sk.composite].filter((attr) => !pkCompositeSet.has(attr));
1315
- if (gsiComposites.some((attr) => removedSet.has(attr))) {
1316
- for (const keyField of [indexDef.pk.field, indexDef.sk.field]) {
1317
- const nameKey = `#r${removeClauses.length}`;
1318
- names[nameKey] = keyField;
1319
- removeClauses.push(nameKey);
1320
- }
1321
- }
1322
- }
1343
+ // Policy-aware GSI key composition. One call covers SETs (full
1344
+ // recompose or half-wise), sparse dropout REMOVEs, and cascade
1345
+ // REMOVEs when an Entity.remove() targets a GSI composite.
1346
+ // See DESIGN.md §7 Policy-Aware GSI Composition.
1347
+ const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, allIndexes, hydratedUpdates, decodedKey, { removedSet });
1348
+ for (const [field, value] of Object.entries(gsiUpdate.sets)) {
1349
+ const nameKey = `#u${counter}`;
1350
+ const valKey = `:u${counter}`;
1351
+ names[nameKey] = field;
1352
+ values[valKey] = toAttributeValue(value);
1353
+ setClauses.push(`${nameKey} = ${valKey}`);
1354
+ counter++;
1355
+ }
1356
+ for (const keyField of gsiUpdate.removes) {
1357
+ const nameKey = `#r${removeClauses.length}`;
1358
+ names[nameKey] = keyField;
1359
+ removeClauses.push(nameKey);
1323
1360
  }
1324
1361
  // ADD (atomic numeric increment / set addition)
1325
1362
  if (uState.add) {
@@ -1677,7 +1714,7 @@ const makeImpl = (config) => {
1677
1714
  const client = yield* DynamoClient;
1678
1715
  const { name: tableName } = yield* tableTag;
1679
1716
  // Decode input
1680
- const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
1717
+ const decodedInput = yield* Schema.decodeUnknownEffect(schemas.inputDecodeSchema)(input).pipe(Effect.mapError((cause) => new ValidationError({
1681
1718
  entityType,
1682
1719
  operation: "upsert.decode",
1683
1720
  cause,
@@ -1697,6 +1734,15 @@ const makeImpl = (config) => {
1697
1734
  let counter = 0;
1698
1735
  // Determine primary key composite attribute names
1699
1736
  const pkComposites = new Set(primaryKeyComposites(config.indexes));
1737
+ // System-colliding fields are written below in the system-field block
1738
+ // (so their semantics — if_not_exists / always-set — stay consistent).
1739
+ const systemColliders = new Set();
1740
+ if (systemFields.createdAtCollision && systemFields.createdAt)
1741
+ systemColliders.add(systemFields.createdAt);
1742
+ if (systemFields.updatedAtCollision && systemFields.updatedAt)
1743
+ systemColliders.add(systemFields.updatedAt);
1744
+ if (systemFields.versionCollision && systemFields.version)
1745
+ systemColliders.add(systemFields.version);
1700
1746
  // Serialize date fields on the decoded item
1701
1747
  if (hasDateFields) {
1702
1748
  serializeDateFields(item);
@@ -1705,6 +1751,8 @@ const makeImpl = (config) => {
1705
1751
  for (const [attr, val] of Object.entries(item)) {
1706
1752
  if (pkComposites.has(attr))
1707
1753
  continue;
1754
+ if (systemColliders.has(attr))
1755
+ continue;
1708
1756
  if (val === undefined)
1709
1757
  continue;
1710
1758
  const nameKey = `#u${counter}`;
@@ -1743,21 +1791,29 @@ const makeImpl = (config) => {
1743
1791
  setClauses.push(`${nameKey} = ${valKey}`);
1744
1792
  counter++;
1745
1793
  }
1746
- // Add createdAt with if_not_exists — only set on first create
1794
+ // Add createdAt with if_not_exists — only set on first create.
1795
+ // User-supplied value wins (domain input → already serialized above);
1796
+ // else fall back to a freshly generated storage primitive.
1747
1797
  if (systemFields.createdAt) {
1798
+ const userSupplied = item[systemFields.createdAt];
1748
1799
  const nameKey = `#u${counter}`;
1749
1800
  const valKey = `:u${counter}`;
1750
1801
  names[nameKey] = systemFields.createdAt;
1751
- values[valKey] = toAttributeValue(generateTimestamp(systemFields.createdAtEncoding));
1802
+ values[valKey] = toAttributeValue(userSupplied !== undefined
1803
+ ? userSupplied
1804
+ : generateTimestamp(systemFields.createdAtEncoding));
1752
1805
  setClauses.push(`${nameKey} = if_not_exists(${nameKey}, ${valKey})`);
1753
1806
  counter++;
1754
1807
  }
1755
- // Add updatedAt — always set to current time
1808
+ // Add updatedAt — user-supplied wins, else always set to current time.
1756
1809
  if (systemFields.updatedAt) {
1810
+ const userSupplied = item[systemFields.updatedAt];
1757
1811
  const nameKey = `#u${counter}`;
1758
1812
  const valKey = `:u${counter}`;
1759
1813
  names[nameKey] = systemFields.updatedAt;
1760
- values[valKey] = toAttributeValue(generateTimestamp(systemFields.updatedAtEncoding));
1814
+ values[valKey] = toAttributeValue(userSupplied !== undefined
1815
+ ? userSupplied
1816
+ : generateTimestamp(systemFields.updatedAtEncoding));
1761
1817
  setClauses.push(`${nameKey} = ${valKey}`);
1762
1818
  counter++;
1763
1819
  }
@@ -1874,29 +1930,48 @@ const makeImpl = (config) => {
1874
1930
  counter++;
1875
1931
  }
1876
1932
  // 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`).
1933
+ // Reuses the policy-aware helper from `.update()`. PK composites are
1934
+ // excluded from the update payload — they're already in the key and
1935
+ // never change during an append. Non-PK composites in appendInput that
1936
+ // overlap a GSI's composites trigger per-GSI resolution via indexPolicy.
1882
1937
  const nonPkAppendFields = {};
1883
1938
  for (const [attr, val] of Object.entries(decoded)) {
1884
1939
  if (!pkCompositeSet.has(attr)) {
1885
1940
  nonPkAppendFields[attr] = val;
1886
1941
  }
1887
1942
  }
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
- });
1943
+ // At append-time, indexPolicy is restricted to composites that are
1944
+ // members of appendInput. Non-appendInput composites are owned by
1945
+ // other writers and are by contract never touched by .append(); their
1946
+ // policy cannot fire at append-time. Each GSI's indexPolicy return is
1947
+ // filtered to appendInput attrs only — non-appendInput keys fall through
1948
+ // to the implicit `"preserve"` default, leaving the half containing
1949
+ // them untouched.
1950
+ const appendInputFieldSet = new Set(Object.keys(appendInputSchema
1951
+ .fields ?? {}));
1952
+ const policyAwareIndexes = {};
1953
+ for (const [indexName, indexDef] of Object.entries(allIndexes)) {
1954
+ if (!indexDef.indexPolicy) {
1955
+ policyAwareIndexes[indexName] = indexDef;
1956
+ continue;
1957
+ }
1958
+ const originalPolicy = indexDef.indexPolicy;
1959
+ policyAwareIndexes[indexName] = {
1960
+ ...indexDef,
1961
+ indexPolicy: (item) => {
1962
+ const raw = originalPolicy(item);
1963
+ const filtered = {};
1964
+ for (const [attr, policy] of Object.entries(raw)) {
1965
+ if (appendInputFieldSet.has(attr) && policy !== undefined) {
1966
+ filtered[attr] = policy;
1967
+ }
1968
+ }
1969
+ return filtered;
1970
+ },
1971
+ };
1898
1972
  }
1899
- for (const [field, value] of Object.entries(gsiKeys)) {
1973
+ const gsiUpdate = KeyComposer.composeGsiKeysForUpdatePolicyAware(schema, entityType, entityVersion, policyAwareIndexes, nonPkAppendFields, decoded);
1974
+ for (const [field, value] of Object.entries(gsiUpdate.sets)) {
1900
1975
  const nameKey = `#a${counter}`;
1901
1976
  const valKey = `:a${counter}`;
1902
1977
  names[nameKey] = field;
@@ -1904,6 +1979,13 @@ const makeImpl = (config) => {
1904
1979
  setClauses.push(`${nameKey} = ${valKey}`);
1905
1980
  counter++;
1906
1981
  }
1982
+ const appendRemoveClauses = [];
1983
+ for (const keyField of gsiUpdate.removes) {
1984
+ const nameKey = `#a${counter}`;
1985
+ names[nameKey] = keyField;
1986
+ appendRemoveClauses.push(nameKey);
1987
+ counter++;
1988
+ }
1907
1989
  // Entity type discriminator — idempotent; ensures existing items
1908
1990
  // without __edd_e__ still get tagged on first append.
1909
1991
  {
@@ -1940,7 +2022,12 @@ const makeImpl = (config) => {
1940
2022
  Object.assign(names, uc.names);
1941
2023
  Object.assign(values, uc.values);
1942
2024
  }
1943
- const updateExpression = `SET ${setClauses.join(", ")}`;
2025
+ const exprParts = [];
2026
+ if (setClauses.length > 0)
2027
+ exprParts.push(`SET ${setClauses.join(", ")}`);
2028
+ if (appendRemoveClauses.length > 0)
2029
+ exprParts.push(`REMOVE ${appendRemoveClauses.join(", ")}`);
2030
+ const updateExpression = exprParts.join(" ");
1944
2031
  // ---------- Build Put of event item ----------
1945
2032
  const eventSk = KeyComposer.composeEventSk(currentSk, newObValue, schema.casing);
1946
2033
  const eventItem = { ...decoded };
@@ -2505,6 +2592,7 @@ const makeImpl = (config) => {
2505
2592
  _resolvedRefs: resolvedRefs,
2506
2593
  /** @internal Full decode pipeline: rename + date deser + schema decode. Used by Batch/Aggregate. */
2507
2594
  _decodeRecord: decodeRecord,
2595
+ _serializeDateFields: serializeDateFields,
2508
2596
  _attachPrototype: attachPrototype,
2509
2597
  _configure: (injectedSchema, injectedTableTag) => {
2510
2598
  schema = injectedSchema;
@@ -2572,26 +2660,12 @@ const makeImpl = (config) => {
2572
2660
  export const bind = (entity) => Effect.gen(function* () {
2573
2661
  const ctx = yield* Effect.context();
2574
2662
  const provide = (effect) => Effect.provide(effect, ctx);
2575
- // Helper: apply combinators to an EntityUpdate, then run in record mode + provide
2576
- const applyUpdate = (op, combinators) => {
2577
- let result = op;
2578
- for (const fn of combinators)
2579
- result = fn(result);
2580
- return provide(result._run("record"));
2581
- };
2582
- // Helper: apply combinators to an EntityPut, then run in record mode + provide
2583
- const applyPut = (op, combinators) => {
2584
- let result = op;
2585
- for (const fn of combinators)
2586
- result = fn(result);
2587
- return provide(result._run("record"));
2588
- };
2589
- // Helper: apply combinators to an EntityDelete, then asEffect + provide
2590
- const applyDelete = (op, combinators) => {
2591
- let result = op;
2592
- for (const fn of combinators)
2593
- result = fn(result);
2594
- return provide(result.asEffect());
2663
+ // Shared config for the bound-CRUD builders — pre-resolved services plus
2664
+ // a typed PathBuilder/ConditionOps so `.condition((t, ops) => ...)` works.
2665
+ const boundCrudConfig = {
2666
+ pathBuilder: createPathBuilder(),
2667
+ conditionOps: createConditionOps(),
2668
+ provide,
2595
2669
  };
2596
2670
  // Helper: apply query combinators then execute
2597
2671
  const applyQuery = (q, combinators) => {
@@ -2613,15 +2687,15 @@ export const bind = (entity) => Effect.gen(function* () {
2613
2687
  return new BoundQueryImpl(q, bqConfig);
2614
2688
  };
2615
2689
  return {
2616
- // CRUD — all ops return Effect (auto-wrapped)
2690
+ // CRUD — fluent bound builders (yieldable, no .run() terminal)
2617
2691
  get: (key) => provide(entity.get(key)._run("record")),
2618
- put: (input, ...combinators) => applyPut(entity.put(input), combinators),
2619
- create: (input, ...combinators) => applyPut(entity.create(input), combinators),
2620
- update: (key, ...combinators) => applyUpdate(entity.update(key), combinators),
2621
- delete: (key, ...combinators) => applyDelete(entity.delete(key), combinators),
2622
- upsert: (input, ...combinators) => applyPut(entity.upsert(input), combinators),
2623
- patch: (key, ...combinators) => applyUpdate(entity.patch(key), combinators),
2624
- deleteIfExists: (key, ...combinators) => applyDelete(entity.deleteIfExists(key), combinators),
2692
+ put: (input) => makeBoundPut(entity.put(input), boundCrudConfig),
2693
+ create: (input) => makeBoundPut(entity.create(input), boundCrudConfig),
2694
+ update: (key) => makeBoundUpdate(entity.update(key), boundCrudConfig),
2695
+ delete: (key) => makeBoundDelete(entity.delete(key), boundCrudConfig),
2696
+ upsert: (input) => makeBoundPut(entity.upsert(input), boundCrudConfig),
2697
+ patch: (key) => makeBoundUpdate(entity.patch(key), boundCrudConfig),
2698
+ deleteIfExists: (key) => makeBoundDelete(entity.deleteIfExists(key), boundCrudConfig),
2625
2699
  // Lifecycle
2626
2700
  getVersion: (key, version) => provide(entity.getVersion(key, version)._run("record")),
2627
2701
  versions: (key) => wrapAsBoundQuery(entity.versions(key)),