Skip to content

v6 — ESM, explicit DDB client, concurrency cap, many fixes#56

Merged
mckalexee merged 42 commits into
mainfrom
v6
Apr 20, 2026
Merged

v6 — ESM, explicit DDB client, concurrency cap, many fixes#56
mckalexee merged 42 commits into
mainfrom
v6

Conversation

@mckalexee

Copy link
Copy Markdown
Contributor

First publish of the v6 line. Breaking changes from v5 touch module format, the DynamoDB client wiring, the cursor format, and the public API surface — migrating existing v5 consumers is not a drop-in upgrade.

Changed (breaking)

  • Package is now ESM ("type": "module") with a single exports entry. CJS consumers on Node ≥ 22.12 can still require() via require(esm); older Node cannot.
  • Minimum Node bumped to >=22.12.0 (the first LTS where require(esm) is unflagged).
  • DynamoDB client must be passed in explicitly on connection.dynamoDb. The library no longer constructs a client internally; consumers own credential chains, endpoint overrides, and retry config. @aws-sdk/client-dynamodb is also a peer dep now, not a direct dep.
  • Cursor format replaced (CBOR → custom binary tuples, base64url-encoded). Cursors minted by v5 will not decode in v6. Pagination state stored from v5 callers is not forward-compatible.
  • Cursors now use URL-safe base64url, not standard base64 (#48).
  • Public API fenced: subpath imports are no longer resolvable. Only the root barrel (import { Facet } from '@faceteer/facet') is supported (#55).
  • Reserved attribute names (PK, SK, facet, ttl, GSI*PK/GSI*SK) are now rejected at both the type level (via WithoutReservedAttributes) and at runtime in the constructor. v5 silently clobbered colliding model fields (#54).
  • Query sort-key arguments are now typed against the active sort key — base-table queries used to accept only GSI SK shapes (#41).
  • ShardConfiguration.keys is restricted to primitive-typed fields at the type level; non-primitive fields are no longer silently hashed as [object Object] (#44).

Added

  • PutOptions.concurrency / DeleteOptions.concurrency / GetOptions.concurrency — cap outer fan-out on batch put/delete/get. Defaults to 8, tuned to sit just above new-on-demand starting capacity. Fixes unbounded fan-out that triggered throttling storms on large batches (#47).
  • Projected reads via select on Facet.get (single + batch) and every PartitionQuery operator. Returns a Pick<T, K | PK | SK> and validates through a new pickValidator factory. PK/SK fields are always re-projected, even if omitted from select.
  • Facet.addIndex(..., { alias }) — register a human-readable alias (facet.PagePostStatus.query(...)) alongside the raw GSIn accessor. Type-level collision check prevents alias/index-name overlap.
  • Tests for projected reads across PartitionQuery methods (equals/beginsWith/between/etc.).
  • Root index.ts barrel widened to cover the full public surface and typedoc-validated.
  • Extensive hover docs on Facet, including guidance on composite sort-key patterns.

Fixed

  • Batch put/delete no longer silently misreport unprocessed items as successful. The final-failure loop now iterates the post-retry UnprocessedItems, not the pre-retry snapshot (#31, #35).
  • Delete batch retry now checks the correct WriteRequest shape, so failure reporting actually fires (#34).
  • Facet.out() strips synthetic ttl even when the facet has no registered indexes, and the delete-ttl step no longer runs N times per read (#33, #49).
  • TTL attributes are now written as the DynamoDB N type with epoch-seconds value, not the raw S-typed ISO string. Date-typed TTL fields were silently broken (#32).
  • deleteSingleItem no longer sends empty ExpressionAttributeNames / ExpressionAttributeValues maps, which DynamoDB rejects with ValidationException. Conditional deletes without value placeholders now succeed (#38).
  • SK.shard configuration is no longer silently dropped by Facet.sk() (#37).
  • buildKey() now honours an explicit shard: 0; v5 treated it as unspecified (#36).
  • addIndex rejects silent overwrite of an already-registered GSI slot or alias (#53).
  • The WithoutReservedAttributes<T> constraint no longer structurally rejects every concrete T: it maps over keyof T so only colliding fields become never.
  • Test helpers now branch on SDK v3 error.name instead of v2 error.code; the reset path was never firing (#40).
  • Removed duplicate this.#PK = PK assignment in the Facet constructor (#39).
  • tsconfig.test.json no longer inherits the exclude pattern that dropped test files from the default type-check graph.

Removed

  • crc-32 npm dependency — shard hashing now uses node:zlib.crc32 (available from Node 20 onward).
  • Dead-code exports from lib/keys.ts: IndexPrivatePropertyMap, isIndex, IndexKeyConfiguration, IndexKeyOptions (#42).

Infrastructure

  • Migrated test runner from Jest → Vitest; CI runs against Node 20, 22, and 24 with DynamoDB Local as a service container.
  • Upgraded to TypeScript 6 with strict flat ESLint v10 + Prettier 3 configuration.
  • Split library and test tsconfigs so published builds no longer contain test files.
  • Widened @aws-sdk/client-dynamodb peer dep range to ^3.0.0.
  • Shared VS Code workspace setting makes IDE auto-imports insert the required .js extension for NodeNext module resolution.

- fix stale aws-sdk import and missing name field in facet examples
- add pagination, FAQs, and best practices content
- document TTL, write sharding, conditional puts, query filters,
  batch/retry limits, and the GSI1..GSI20 range
Replaces jest/ts-jest/ts-node/@types/jest/jest-junit with vitest and
@vitest/coverage-v8. ts-node is no longer needed since vitest handles
TypeScript natively. Vitest's built-in junit reporter replaces
jest-junit; coverage now uses v8. Test file is unchanged — globals
(describe/test/expect/beforeAll) stay available via globals: true and
vitest/globals types.

Also tightens engines.node to >=20.19.0 to match vite 8's floor.
Bumps typescript devDep from ^5.0.0 to ^6.0.0. TS 6 deprecates
moduleResolution: "node" and requires module/moduleResolution to be
paired, so both switch to "NodeNext" — still emits CommonJS because
package.json has no "type": "module".
Adds an explicit include to tsconfig.json and excludes **/*.test.ts so
test files no longer get compiled next to sources and picked up by
package.json's files glob — previously lib/facet.test.js (~12 kB)
and its .d.ts were shipping to npm.

Moves vitest/globals out of the library tsconfig into a new
tsconfig.test.json (extends the root, noEmit) so vitest's ambient
declarations don't load during the library build. Adds a
typecheck:tests script and wires it into CI so the test-only config
can't silently break.

Also drops the strict sub-flags (noImplicitThis, alwaysStrict,
strictBindCallApply, strictNullChecks, strictFunctionTypes,
strictPropertyInitialization) already implied by strict: true.
deleteBatch was inspecting `unprocessedRequest.PutRequest?.Item` to
attribute failures, but a delete batch never contains PutRequest
entries, so the branch was dead code and every item was reported as
deleted even when DynamoDB had refused the write after five retries.
Both deleteBatch and putBatch also looked at the original
batchWriteItem response's UnprocessedItems instead of the post-retry
list, so items that succeeded on retry were still reported as failed.

Extract a shared `batchWriteWithRetry` helper that takes a small
adapter describing how to translate the domain item to a WriteRequest
(and back, for failure attribution). put and delete now each supply a
3-method adapter. Closes #31 and #51.

Add `lib/batch-write.test.ts` regression tests that stub batchWriteItem
with vi.spyOn — the existing integration tests never hit this path
because DynamoDB Local does not throttle.
…conds

Facet#in computed a normalised `ttlAttribute` (Date → unix seconds,
numeric string → number) but then wrote `model[this.ttl]` to the
output record, discarding the normalisation. For Date TTLs the
converter marshals the value as `{ S: <iso-string> }`, which
DynamoDB's TTL reaper ignores — items silently outlive their TTL.
Numeric strings were likewise stored as `{ S }`.

Use the normalised number in the output record, fix the missing
parseInt radix, and narrow the intermediate type to `number |
undefined` so marshalling always produces `{ N }`. Add integration
tests that read the raw item via getItem and assert `ttl.N` for both
Date and string inputs. Closes #32.
The `delete recordToValidate['ttl']` lived inside the registered
indexes loop, so a facet configured with a TTL but no GSIs would
leak the synthetic `ttl` attribute into the validator. Move it after
the loop so it runs exactly once per out() call.

Also tighten the intermediate type from `any` to
`Record<string, unknown>`. Closes #33.
…lders

deleteSingleItem copied `names` and `values` onto the DeleteItemInput
unconditionally. DynamoDB rejects requests where
ExpressionAttributeNames/Values is present but empty, which the
common condition form like `['fieldName', 'exists']` produces (empty
values map, non-empty names map). putSingleItem already guarded
against this; delete was the copy-paste miss.

Extract the guarded assignment into `applyCondition` in
`lib/condition.ts` and use it from both put and delete so the two
paths cannot drift again. Adds `Conditional Deletes` integration
test. Closes #38.
The shard-selection branch used `if (shard)` to decide between "use
the caller-supplied shard" and "hash from model" — a falsy check that
silently fell through to the hash path when `shard === 0`. So
`facet.query(partition, 0)` could not target shard 0, and `pk(model,
0)` returned a hash-derived key.

Switch to `shard !== undefined`. The outer guard already handles
`null` (explicit opt-out), so this only changes behaviour for
`shard === 0`. Add a regression test that asserts each of the four
shards on a sharded GSI produces a distinct, well-formed PK and that
one of them matches the hashed default. Closes #36.
`Facet#sk` and `FacetIndex#sk` were coalescing an unset `shard`
argument to `null` before calling `buildKey`. `buildKey`'s outer
guard is `shard !== null`, so the coalescing short-circuited the
entire shard block and any `KeyConfiguration.shard` on a sort key
was silently discarded — including on `in()` writes, which go
through `this.sk(model)` with no shard argument.

Drop the `?? null`. The pk/sk call paths now treat
`shard: undefined` consistently as "compute from model", matching
`Facet#pk` which already had this behaviour. Closes #37.
addIndex had a hasOwnProperty guard for the alias but none for the
GSI slot itself. Calling addIndex twice with the same index would
overwrite `#indexes.get(index)` and `this[index]` while leaving any
prior alias dangling against a stale FacetIndex. The type-level
cast made it compile cleanly, so mistakes were silent.

Add the same guard for the index slot and hoist both guards to the
top so a failed check doesn't leave the facet half-mutated (the old
alias check ran after `#indexes.set` and `Object.assign(this,
{[index]})` had already fired). Closes #53.
Facet.in builds the stored record as `{...model, ...facetKeys, facet,
ttl}`. If the model itself had a field named PK, SK, facet, ttl, or
GSIxPK/GSIxSK, the synthetic key silently overwrote it and the
original value vanished on the round trip (since Facet.out strips
those same attribute names). No compile error, no runtime warning.

Two complementary fixes:

1. Add a `ReservedAttributeName` template-literal type (`PK` | `SK` |
   `facet` | `ttl` | `` `GSI${number}PK` `` | `` `GSI${number}SK` ``)
   and a `WithoutReservedAttributes` constraint (optional `?: never`
   fields). `Facet`, `FacetOptions`, `FacetIndex`, `FacetIndexKeys`,
   `AddIndexOptions`, `PartitionQuery`, and every put/get/delete
   helper now require `T extends WithoutReservedAttributes`. A model
   that declares one of the reserved names now fails to compile.

2. `Facet.in` calls `assertNoReservedAttributes(model)` so a dynamic
   caller that bypasses the types (e.g., JSON.parse output) gets a
   thrown error instead of a silent clobber.

Tests cover the runtime guard directly and the type-level guard via
`@ts-expect-error` on `Facet<{PK: string}>`, `Facet<{ttl: number}>`,
and `Facet<{GSI1PK: string}>`. Closes #54.
Idempotent at runtime but a plain typo. Closes #39.
aws-sdk v3 errors expose the exception class name via `.name` (and
`instanceof`), not via the v2 `.code` field. The createTable catch
block in the test helper was checking `error.code ===
'ResourceInUseException'`, which is always undefined — so the
"reset the existing TEST table if it already exists" branch never
fired. The suite passed on fresh Docker containers but would see
stale data on warm ones and fail deterministic assertions like
`expect(posts.records.length).toBe(300)`.

Switch to `instanceof ResourceInUseException` and re-throw anything
else so setup errors stop being silently swallowed.

Verified by running `npm test` twice against the same persistent
DynamoDB Local container — both runs green. Closes #40.
The base tsconfig.json excludes **/*.test.ts from its compile set.
tsconfig.test.json's own include was re-adding them, but exclude is
not overridden on extends so the inherited exclusion won. As a result
npm run typecheck:tests was silently checking zero test files — a
gap that masked a broken constraint (see follow-up) for the whole
v6 branch.
The constraint was { [K in ReservedAttributeName]?: never } — a mapped
type over the template-literal union `GSI\${number}PK`/`GSI\${number}SK`
which TypeScript realises as an index signature. No concrete model
type structurally matches that signature, so the bound rejected every
`T`, not just types with reserved keys. The tsconfig.test.json bug
hid this because tests (the only places concrete Ts appear) were never
type-checked.

Switch to { [K in keyof T]: K extends ReservedAttributeName ? never : T[K] }
and use `T extends WithoutReservedAttributes<T>` as the bound, so a
model that names a reserved field has at least one impossible property
and fails while clean models pass.

Also change the PK/SK default generics from `Keys<T>` to `never` so
`SK: { keys: [] }` configurations infer SK correctly (no required sort
key fields on `get(...)`), rather than widening to the full key set.

BREAKING: exported `WithoutReservedAttributes` now takes a type
parameter. Consumers writing `Facet<T, ...>` via the library's own
generics are unaffected; direct users of the constraint type need
to update to `WithoutReservedAttributes<T>`.
Users can opt into DynamoDB ProjectionExpression reads by passing
{ select: ['attr1', 'attr2'] } to Facet.get(). The return type narrows
to Pick<T, K | PK | SK> — the facet's PK/SK fields are always included
because callers need them for identity and round-tripping.

Projected reads need a PickValidator, a factory that derives a
sub-validator for any requested subset of keys. The factory shape
matches how real validator libraries work: the outer function runs
once per key-set (expensive derive step — Zod .pick(), Ajv compile()),
the returned function runs per record (cheap parse). Worked Zod and
Ajv examples in the PickValidator tsdoc.

The capability is gated at compile time. Facet now carries a phantom
generic PV on top of <T, PK, SK>; the exported `Facet` const is typed
by FacetConstructor, whose overloads narrow PV to PickValidator<T>
when the opts object contains a pickValidator, or to undefined
otherwise. The projected get overloads use
`this: [PV] extends [PickValidator<T>] ? this : never`, so
`.get(q, { select: [...] })` on a facet configured without a
pickValidator is a type error rather than a runtime throw. Facet.pick
keeps a defensive runtime throw as a safety net for paths that bypass
the type gate (upcasts, direct calls).

Projection on query (PartitionQuery / FacetIndex.query) is not yet
implemented — only Facet.get reads honour select. The expression
builder, auto-included key fields, and pickValidator wiring are
positioned to extend to queries in a follow-up.

Exposed new exports: Facet (both value and type), FacetConstructor,
PickValidator, WithoutReservedAttributes (now a generic).

Assumption (noted in CLAUDE.md): GSIs are created with
ProjectionType: ALL. Get uses the base table and isn't affected,
but the assumption is load-bearing for the planned query-level
projection extension.
Every public method on Facet and PartitionQuery now carries a real
tsdoc summary, @param / @returns, and at least one @example block.
VS Code's hover and TypeDoc's generated reference both benefit.

Facet.get's overload order was reversed: the single-item forms now
come first, so IDE parameter hints show "Pick<T, PK | SK>" property
completions at the caret instead of Array.prototype methods when
the caller types `get({`. The runtime behaviour is unchanged.

Also annotate the one intentional no-redeclare on the exported
`Facet` identifier, which is both a type alias and the typed
constructor const.

In the README, add a "Composite sort keys" section that explains
lexicographic comparison of the joined sort key, and calls out the
greaterThan/lessThan bleed that happens when the query crosses
leading-field values. Add a "Query pattern reference" section that
maps common access patterns ("most recent record", "range scoped to
one value of a leading SK field", etc.) to their idiomatic call. Add
a Best Practices bullet pointing at the new section so readers who
skim the bullets still see the warning.
Local scratchpad for one-off type-ergonomics tests (e.g. the
EmailMetadata playground) that should never ship.
Gives the TypeScript language server and CLI visibility into files
under _scratch/ without affecting what ships. The directory is git-
ignored, compiled artifacts (_scratch/**/*.js and .d.ts) are not
covered by the package.json "files" allowlist, and the pattern is a
no-op when _scratch/ doesn't exist.
Extends the select projection capability that ships on Facet.get to
cover every PartitionQuery operator: equals, greaterThan,
greaterThanOrEqual, lessThan, lessThanOrEqual, beginsWith, list,
first, and between. Works on base-facet queries and index queries
alike.

Threads a PV phantom generic through PartitionQuery, PartitionQueryOptions,
FacetIndex, FacetIndexKeys, Facet.query, and FacetIndex.query so the
facet's "does it have a pickValidator?" state flows into the query
surface.

Each operator now has two typed overloads plus the implementation:

- A plain overload whose options type is
  QueryOptions<...> & { select?: never }. The select?: never guard is
  load-bearing: TypeScript resolves overloads top to bottom, so without
  it a projected call would bind to the plain overload and skip the
  gate entirely. The guard forces the plain overload to reject any
  options object that carries select.

- A projected overload gated by
  `this: [PV] extends [PickValidator<T>] ? this : never`. Callers on
  facets constructed with a pickValidator resolve to this signature
  and get QueryResult<Pick<T, K | AutoKeys<PK, SK, GSIPK, GSISK>>>.
  Callers on facets without a pickValidator get a compile error.

AutoKeys is PK | SK for base-facet queries and PK | SK | GSIPK | GSISK
for index queries. The index case relies on the library's documented
assumption that GSIs are created with ProjectionType: ALL, which makes
the base-table key fields available on the index.

Execution paths are refactored around three private single-signature
helpers — #compareExec, #beginsWithExec, #betweenExec — plus a shared
#execute that merges cursor, filter, and projection into the
QueryInput before running it. Public impls route through these
helpers, not through other public methods, so overload resolution on
internal dispatch can't drag the plain overload's select?: never guard
into a projected call site. Adds a keyFields accessor on FacetIndex
mirroring the one on Facet so PartitionQuery can auto-include the
index's PK/SK field names on projected index queries.

Tests for projection on query land separately.
Integration tests covering every operator (equals, greaterThan,
greaterThanOrEqual, lessThan, lessThanOrEqual, beginsWith, list,
first, between) with select, plus:

- Base-facet vs index-facet auto-key inclusion (index queries
  auto-include both the base PK/SK fields and the index GSIPK/GSISK
  fields).
- Type-level narrowing verified via @ts-expect-error probes on
  fields that weren't selected.
- Type-level gate verified via @ts-expect-error on every operator
  of a facet constructed without a pickValidator.
- Dedup when select repeats a key or overlaps with an auto-included
  field.
- filter + select combined (the placeholder namespaces — #F_* for
  filter, #p* for projection, #PK/#SK for the key condition — are
  documented as disjoint, this confirms they merge cleanly at
  runtime).
- Cursor pagination across a projected query (confirms the opaque
  LastEvaluatedKey survives being emitted by a projected read).

47 tests pass (up from 29).
Adds a "Projected reads" section to the README covering the feature
end to end: why (payload size and JSON-parse cost, not read
capacity), the pickValidator factory shape, configuring the facet,
using select on Facet.get and on every PartitionQuery operator,
auto-inclusion of base PK/SK plus index GSIPK/GSISK fields on index
queries, the compile-time gate on facets without a pickValidator,
and the opt-out identity pickValidator pattern. Adds a new entry in
the existing Query pattern reference for projected queries, and a
Best Practices bullet noting that ProjectionExpression reduces the
wire payload but not DynamoDB's read-capacity charge.

Extends tsdoc on the nine PartitionQuery methods. equals, list,
first, beginsWith, and between each gain a second worked @example
showing a projected call. The four remaining comparison operators
(greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual) get a
short @remarks pointing readers to equals for the projected shape,
rather than duplicating the same example four more times.
Sort-key args on equals/greaterThan*/lessThan*/beginsWith/between were
typed `Partial<Pick<T, GSISK>>`. For base-facet queries GSISK is `never`,
collapsing to `{}` — the sort arg accepted any object shape and only the
string escape hatch was meaningfully typed.

Add an `ActiveSK<SK, GSIPK, GSISK>` helper that picks `SK` on base queries
(where GSIPK is `never`) and `GSISK` on index queries, and thread it
through every public overload. No new generics on PartitionQuery.

Fixes #41.
Four exports were declared but never consumed anywhere — not in lib, not
in tests, not re-exported from index.ts:

  - IndexPrivatePropertyMap (20-line `_GSI*` name map)
  - isIndex (type guard)
  - IndexKeyConfiguration
  - IndexKeyOptions (~60-line interface with 40 generic parameters)

Drop all four. Add a compile-time regression test that re-adding any of
them fails type-checking.

Fixes #42.
shard.keys was typed `Keys<T>[]`, so any property of T qualified — including
Date, object, and array fields. Those would silently contribute
`[object Object]`, a timezone-dependent string, or a joined array to the
CRC hash input. Deterministic, but an opaque footgun.

Add a `PrimitiveShardKey<T>` helper that picks keys whose value extends
`string | number | bigint | boolean | undefined`. The tuple wrap
(`[T[K]] extends [...]`) keeps the check non-distributive so a union
like `string | Date` is rejected as a whole rather than matching on the
primitive half.

KeyConfiguration.keys is intentionally left unchanged — buildKey already
handles Date via toISOString for composite keys, and silently skips
other non-primitives. The narrowing only matters for the hash path.

Fixes #44.
Matches the style already used in put.ts, delete.ts, and condition.ts.
Query.ts was the last namespace-import holdout. Alias the imported
`filter` function as `buildFilterExpression` to avoid colliding with
the local `filter` variable destructured from options.

Behaviour-only refactor; existing filter tests cover the change.

Fixes #45.
The cursor encodes DynamoDB's LastEvaluatedKey: a small map of
attribute names to AttributeValue objects. Two invariants this library
maintains let us skip CBOR's general-purpose encoding entirely:

  1. Every key value is a string. `buildKey` only emits strings, so
     LastEvaluatedKey always contains `{S: string}` values.
  2. Attribute names come from a fixed 42-name set — `PK`, `SK`, and
     `GSI1PK..GSI20SK`. Each encodes to a single byte.

New format: base64url of a byte stream of (code:u8)(len:varint)(utf8)
tuples. On a realistic 4-entry GSI cursor this is about 45% smaller
than base64url(JSON) and about 20% smaller than the previous CBOR
encoding, with zero runtime dependencies.

Varint decoding uses Number arithmetic rather than 32-bit bitwise
shifts so values above 2^31 don't wrap silently, and rejects input
that would overflow MAX_SAFE_INTEGER. Also switches base64 to
base64url so cursors are URL-safe.

Breaking: cursors minted by earlier versions won't decode. Acceptable
for v6.

Drops the `cbor` dependency.
Facet's constructor used to fall back to `new DynamoDB({})` when the
caller omitted `connection.dynamoDb`. That silently produced a client
with default config — wrong region, wrong credentials, wrong endpoint
— and was the only runtime use of `@aws-sdk/client-dynamodb` in the
library. Drop the fallback. `connection.dynamoDb` is now required.

Convert the SDK import to `import type`. Every remaining use in the
library is a type reference; the emitted .js files no longer import
the SDK at all, so the library is purely type-coupled to it.
Previously the peer declared `>=3.0.0 <=3.859.0`, which pinned below
the current SDK minor. Every consumer on a newer SDK (i.e. most of
them; latest is 3.1032.0) was triggering a peer warning or forced
into duplicate installs.

The tagged-union `AttributeValue` shape this library depends on has
been stable since 3.0.0 GA (Dec 2020), and AWS SDK v3 has shipped
~1000 minors since without a v4 on the horizon. `^3.0.0` matches the
ecosystem pattern (dynamodb-toolbox et al.) and accepts any current
or future 3.x.

Dev dep bumped to `^3.1032.0` so CI exercises a current SDK; the
lockfile supplies run-to-run reproducibility, so the manifest doesn't
need to pin.
Two bugs in the previous sharding logic:

1. `CRC32.bstr(string) >>> 1 % shardCount` in crc-shard.ts: `>>> 1` was
   meant to coerce signed int32 to unsigned, but the correct idiom is
   `>>> 0`. The `>>> 1` form silently discards the low bit, losing one
   bit of entropy. Distribution was still uniform but the low bit of
   the CRC was never used on power-of-two shard counts.

2. `valuesToHash.join('')` in keys.ts: multi-key shard configs were
   concatenated without a separator, so `{a: 'ab', b: 'c'}` and
   `{a: 'a', b: 'bc'}` hashed to the same shard. Now joined with a
   null byte, which can't appear in real IDs and never leaves the
   hash function.

Also swap the `crc-32` npm package (~100KB, pure JS) for Node's
built-in `node:zlib.crc32` (backported to 20.15.0 so the existing
engines floor of 20.19.0 covers it). Native implementation, hardware
acceleration where available, drops a dependency.

Breaking for v6: existing sharded data will rebalance. Single-key
ASCII, single-key non-ASCII, and multi-key configs all see new shard
assignments.

Tests added: dedicated crc-shard test file (determinism, range,
padding, uniform distribution, NaN clamping, fractional truncation,
UTF-8 test vector `"123456789" → 0xcbf43926`) and a multi-key
delimiter collision test proving the `\x00` join prevents the old
stringify-concat collision.
Previously `tsconfig.json` was the build config that excluded tests,
and `tsconfig.test.json` was a separate config with `vitest/globals`
used only by `npm run typecheck:tests`. Since VS Code picks the
nearest tsconfig for a given file, opening a test file landed on
tsconfig.json — which excluded the file, so the language service
fell back to defaults and flagged `expect`/`test`/`describe` as
unresolved.

Invert the split: tsconfig.json is now the dev-wide view covering
lib + tests + scratch + vitest.config.ts, with vitest/globals in
`types` and `noEmit: true`. A new `tsconfig.build.json` extends it,
flips noEmit off, adds declaration/removeComments, and excludes
tests. That's the config used by `npm run build`.

The test-only config is gone. `npm run typecheck` covers everything
via `tsc --noEmit` against the base config (replacing the former
`npm run typecheck:tests`). CI step renamed to match.
This branch has already landed multiple breaking changes: required
`connection.dynamoDb` (no default client fallback), cursor format
rewrite, shard-id algorithm changes, and widened AWS SDK peer range.
SemVer demands the major bump; setting the manifest now makes the
intent explicit rather than carrying 5.0.0 through the v6 branch.
Match the convention for mid-sized TypeScript libraries (kysely,
drizzle-orm, dynamodb-toolbox, zod, valibot): export everything
useful from the root, and fence `./lib/**` subpath imports via the
`exports` field in package.json.

index.ts gains:

  - `PK`, `SK`, `IndexKeyNameMap` constants for CDK / table-setup
    code that would otherwise hardcode attribute names.
  - `buildKey` for advanced callers constructing composite keys
    outside a facet (migrations, cross-facet scatters).
  - `FacetIndex` and `PartitionQuery` as value exports alongside
    their existing type exports, enabling `instanceof` checks and
    completing the symmetry with `Facet`.
  - `GetOptions` type — real gap; siblings `PutOptions`,
    `DeleteOptions`, `QueryOptions` were already exported.
  - `Keys`, `PrimitiveShardKey`, `ReservedAttributeName` utility
    types for users writing their own helpers against facet
    models.

package.json gains an `exports` field exposing only `.` and
`./package.json`. Subpath imports like
`require('@faceteer/facet/lib/keys')` now fail with
`ERR_PACKAGE_PATH_NOT_EXPORTED`. Breaking for anyone reaching into
internals, appropriate on v6. The benefit: internal file layout
under `lib/` becomes freely refactorable without breaking
consumers.

Added `@internal` JSDoc to `FacetIndex`'s constructor to steer
consumers toward `Facet.addIndex(...)` — a manually constructed
`FacetIndex` isn't wired into the parent facet's index map and
would be inert.

Cursor helpers, shard helper, batch internals, condition helpers,
and direct impl functions (`putSingleItem`, `getBatch`, etc.) stay
internal by omission. They're wrapped by `Facet` methods and
exposing them would invite coupling to implementation details.
- migrate to ESLint 10 flat config (eslint.config.mjs) using defineConfig
  from eslint/config; delete legacy .eslintrc.js
- replace @typescript-eslint/{parser,eslint-plugin} with the unified
  typescript-eslint v8 package
- layer @eslint/js recommended, strict-type-checked, stylistic-type-checked,
  eslint-plugin-import-x (no-cycle + no-extraneous-dependencies only), and
  eslint-config-prettier/flat
- drop eslint-plugin-prettier: Prettier runs standalone via npm run
  format / format:check, per prettier.io guidance
- remove unused plugins: eslint-config-google (unmaintained since 2019),
  eslint-config-standard, eslint-plugin-import, eslint-plugin-n,
  eslint-plugin-promise, eslint-import-resolver-node
- bump typedoc to ^0.28.19 so npm install resolves cleanly under TS 6
  without --legacy-peer-deps
- fix strict-type-checked findings across put/delete/facet/query (unsafe
  any unmarshalling, forbidden non-null assertions, dynamic delete on
  reserved keys)
- add lint/format/format:check scripts and wire them into CI
Fixes #47. The batch helpers chunked input into 25/25/100-item batches
then fired every batch at once via Promise.all(Settled), so a 10k-item
call produced 400 concurrent BatchWriteItem / 100 concurrent
BatchGetItem requests — enough to exhaust on-demand burst capacity
and touch off SDK retry storms on a hot table.

Add a small mapWithConcurrency helper and route the outer fan-out
through it. Each of PutOptions / DeleteOptions / GetOptions gains a
concurrency?: number field; default is 8, tuned to sit just above a
new on-demand table's starting 4k WCU (25-item batches × ~30ms p50 ≈
830 WCU/s per in-flight worker, so 8 workers ≈ 6.7k WCU/s) while
staying well under the AWS SDK v3 http maxSockets default.
- Version → 6.0.0-alpha.0 to make the pre-publish status explicit.
- Switch to type: module with an ESM-only exports map. Relative
  imports carry explicit .js extensions, as NodeNext requires.
- Bump engines to >=22.12.0 so consumers land on the first Node LTS
  with require(esm) unflagged. CJS callers can still require() the
  package on supported runtimes.
- Add sideEffects: false so bundlers can tree-shake unused exports.
Carve .vscode/settings.json out of the .vscode/ ignore so everyone
who opens the repo gets the same import-module-specifier-ending
preference. With the project on ESM + NodeNext, TypeScript now
requires explicit .js extensions on relative imports; this setting
makes the VS Code auto-import suggestion insert them automatically.
Other .vscode/ files remain personal.
Mirrors faceteer/cdk's publish pipeline (tag push → full CI → OIDC
Trusted Publishing → dist-tag picked from the version suffix) and
adds one step cdk is missing: the workflow lifts the matching
`## [X.Y.Z]` block out of CHANGELOG.md and uses it as the body of
the GitHub Release, so every version leaves a human- and
LLM-readable record of what shipped.

Seeds CHANGELOG.md with a [Unreleased] block and an initial
[6.0.0-alpha.0] entry covering the full v5 → v6 diff. Documents the
release recipe in CLAUDE.md so future sessions can cut a tag from a
short script: draft notes from git log, rename [Unreleased] to the
new version, bump package.json, commit, tag, push.

CLAUDE.md's Node-version line was also stale after the earlier
ESM migration; updated to match the current >=22.12 engines range.
@codecov

codecov Bot commented Apr 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.41259% with 36 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.52%. Comparing base (840944d) to head (5ac4b9b).
⚠️ Report is 43 commits behind head on main.

Files with missing lines Patch % Lines
lib/facet.ts 82.22% 4 Missing and 4 partials ⚠️
lib/get.ts 78.37% 4 Missing and 4 partials ⚠️
lib/delete.ts 71.42% 4 Missing and 2 partials ⚠️
lib/put.ts 66.66% 4 Missing and 2 partials ⚠️
lib/batch-write.ts 90.00% 1 Missing and 2 partials ⚠️
lib/condition.ts 50.00% 1 Missing and 2 partials ⚠️
lib/query.ts 96.15% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #56      +/-   ##
==========================================
+ Coverage   79.34%   87.52%   +8.18%     
==========================================
  Files           9       13       +4     
  Lines         489      465      -24     
  Branches      123       92      -31     
==========================================
+ Hits          388      407      +19     
+ Misses        101       32      -69     
- Partials        0       26      +26     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mckalexee mckalexee merged commit 8f5e41f into main Apr 20, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant