@atscript/db-memory 0.1.152 → 0.1.154

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/index.cjs CHANGED
@@ -428,8 +428,8 @@ function buildMemoryPredicate(filter, relationSets) {
428
428
  * there is exactly ONE implementation. Everything here is a pure function over
429
429
  * plain `Record<string, unknown>` rows — no adapter/table state — so other
430
430
  * consumers (e.g. moost-db's value-help controller) can reuse the SAME engine
431
- * instead of hand-rolling a second copy. The adapter wires its own PK-derived
432
- * tie-break / physical-PK fields in as parameters.
431
+ * instead of hand-rolling a second copy. The adapter appends its own
432
+ * physical primary-key fields to `$sort` before calling in.
433
433
  *
434
434
  * The dot-path READ / DELETE helpers (`getPath` / `deletePath`) are the
435
435
  * core's (`@atscript/db`); the dot-path WRITE helper ({@link setPath}) lives
@@ -455,6 +455,24 @@ function compareLeaves(a, b) {
455
455
  return as < bs ? -1 : as > bs ? 1 : 0;
456
456
  }
457
457
  /**
458
+ * {@link compareLeaves} with a NULL placement: `'first'` / `'last'` put
459
+ * `null`/`undefined` before / after every value, and `desc` reverses only the
460
+ * comparison of two values (since 0.1.153). Without `nulls`, nil is the
461
+ * smallest value and `desc` reverses everything, as before.
462
+ */
463
+ function compareSortKey(a, b, desc, nulls) {
464
+ if (nulls) {
465
+ const aNil = a === null || a === void 0;
466
+ const bNil = b === null || b === void 0;
467
+ if (aNil || bNil) {
468
+ if (aNil && bNil) return 0;
469
+ return (aNil ? -1 : 1) * (nulls === "first" ? 1 : -1);
470
+ }
471
+ }
472
+ const cmp = compareLeaves(a, b);
473
+ return desc ? -cmp : cmp;
474
+ }
475
+ /**
458
476
  * Dot-path setter used by inclusion projection. Creates intermediate plain
459
477
  * objects as needed; overwrites a non-object intermediate. Top-level keys and
460
478
  * nested dot-paths both work.
@@ -479,24 +497,29 @@ function setSegments(target, segments, value) {
479
497
  * - No `$sort` (or an empty one) → returns the input array UNCHANGED (same
480
498
  * reference, insertion order preserved) — the fast path for an unsorted read.
481
499
  * - `tieBreak`, when supplied, is the FINAL deterministic tie-break key for a
482
- * TOTAL order — the adapter injects its {@link MemoryAdapter.pkKey} here so
483
- * rows with equal sort keys still order deterministically. When ABSENT the
500
+ * TOTAL order, so rows with equal sort keys still order deterministically
501
+ * (the adapter instead appends its primary-key fields to `$sort`, compared
502
+ * by value like every other key — since 0.1.153). When ABSENT the
484
503
  * sort falls back to preserving input order among equal keys (via each row's
485
504
  * original index), so a consumer with no primary key keeps insertion order.
486
505
  * - `topK` (since 0.1.151), when supplied, asks for only the first `topK` rows
487
506
  * of that order (e.g. `$skip + $limit`): a small one is selected in one pass
488
507
  * instead of sorting everything. The rows returned are exactly the head of
489
508
  * the full sort — same comparator, same stable tie handling.
509
+ * - `nulls` (since 0.1.153) — the `$nulls` control: a `$sort` key with an
510
+ * entry puts `null`/missing values `'first'` or `'last'` in either
511
+ * direction; a key without one keeps them smallest.
490
512
  *
491
513
  * NEVER mutates the input array. Each row's sort keys are read ONCE (O(n)) and
492
514
  * its `tieBreak` only when two rows tie on every key — not inside the
493
515
  * O(n log n) comparator.
494
516
  */
495
- function sortRows(rows, $sort, tieBreak, topK) {
517
+ function sortRows(rows, $sort, tieBreak, topK, nulls) {
496
518
  const keys = $sort ? Object.entries($sort) : [];
497
519
  if (keys.length === 0) return rows;
498
520
  const readers = keys.map(([field]) => pathReader(field));
499
521
  const desc = keys.map(([, dir]) => dir === -1);
522
+ const placement = nulls ? keys.map(([field]) => Object.hasOwn(nulls, field) ? nulls[field] : void 0) : [];
500
523
  const decorated = rows.map((row, index) => ({
501
524
  row,
502
525
  index,
@@ -508,8 +531,8 @@ function sortRows(rows, $sort, tieBreak, topK) {
508
531
  }));
509
532
  const compare = (a, b) => {
510
533
  for (let i = 0; i < desc.length; i++) {
511
- const cmp = compareLeaves(a.keys[i], b.keys[i]);
512
- if (cmp !== 0) return desc[i] ? -cmp : cmp;
534
+ const cmp = compareSortKey(a.keys[i], b.keys[i], desc[i], placement[i]);
535
+ if (cmp !== 0) return cmp;
513
536
  }
514
537
  if (tieBreak) {
515
538
  const at = a.tie ??= tieBreak(a.row);
@@ -790,7 +813,7 @@ function aggregateRows(rows, controls) {
790
813
  const having = controls.$having;
791
814
  if (having) out = out.filter(buildMemoryPredicate(having));
792
815
  if (controls.$count) return [{ count: out.length }];
793
- const paged = paginate(sortRows(out, controls.$sort), controls.$skip, controls.$limit);
816
+ const paged = paginate(sortRows(out, controls.$sort, void 0, void 0, controls.$nulls), controls.$skip, controls.$limit);
794
817
  const fields = ($select === void 0 ? groupBy : $select.asArray ?? []).map((field) => [field, pathReader(field)]);
795
818
  const outputAliases = $select === void 0 ? [] : [...($select.buckets ?? []).map((b) => b.alias), ...$select.computedAliases];
796
819
  return paged.map((row) => {
@@ -866,18 +889,19 @@ function exprAccumulatorFactory(e) {
866
889
  /**
867
890
  * `first` / `last`: the value of `fl.column` on the group's representative
868
891
  * row — the least (`first`) or greatest (`last`) row by `rowOrder` (NULL
869
- * smallest; the primary key is the final key, so the pick is deterministic).
892
+ * smallest unless the key carries a `nulls` placement; the primary key is the final key, so the pick is deterministic).
870
893
  */
871
894
  function firstLastFactory(fl, rowOrder) {
872
895
  const keys = rowOrder.map((k) => ({
873
896
  read: pathReader(k.column),
874
- sign: k.desc ? -1 : 1
897
+ desc: k.desc,
898
+ nulls: k.nulls
875
899
  }));
876
900
  const read = pathReader(fl.column);
877
901
  const sign = fl.fn === "first" ? -1 : 1;
878
902
  const compare = (a, b) => {
879
903
  for (const k of keys) {
880
- const c = compareLeaves(k.read(a), k.read(b)) * k.sign;
904
+ const c = compareSortKey(k.read(a), k.read(b), k.desc, k.nulls);
881
905
  if (c !== 0) return c;
882
906
  }
883
907
  return 0;
@@ -1581,14 +1605,28 @@ var MemoryAdapter = class MemoryAdapter extends _atscript_db.BaseDbAdapter {
1581
1605
  return { deletedCount: 1 };
1582
1606
  }
1583
1607
  /**
1584
- * Stable multi-key comparator from `$sort`, with a final tie-break on
1585
- * {@link pkKey} for a deterministic total order. Returns the input unchanged
1608
+ * Stable multi-key comparator from `$sort`, with the primary-key fields it
1609
+ * leaves out appended in the direction of its LAST key and compared by
1610
+ * value (numbers numerically, `Date`s by instant) — the same tie-breaker
1611
+ * the core appends before a read reaches any adapter (since 0.1.153), so a
1612
+ * direct adapter call orders ties the same way. Returns the input unchanged
1586
1613
  * (insertion order) when there is no `$sort`. Delegates to the shared pure
1587
- * {@link sortRows}, injecting {@link pkKey} as the total-order tie-break;
1588
- * `topK` asks for the head of that order only (`$skip + $limit`).
1614
+ * {@link sortRows}; `topK` asks for the head of that order only
1615
+ * (`$skip + $limit`).
1589
1616
  */
1590
- _sortRows(rows, $sort, topK) {
1591
- return sortRows(rows, $sort, (r) => this.pkKey(r), topK);
1617
+ _sortRows(rows, $sort, $nulls, topK) {
1618
+ return sortRows(rows, this._withPkTieBreak($sort), void 0, topK, $nulls);
1619
+ }
1620
+ /** `$sort` plus the primary-key fields it leaves out, in its last key's direction. */
1621
+ _withPkTieBreak($sort) {
1622
+ if (!$sort || !hasSort($sort)) return $sort;
1623
+ const keys = Object.keys($sort);
1624
+ const missing = this._physicalPkFields().filter((field) => !(field in $sort));
1625
+ if (missing.length === 0) return $sort;
1626
+ const dir = $sort[keys[keys.length - 1]];
1627
+ const next = { ...$sort };
1628
+ for (const field of missing) next[field] = dir;
1629
+ return next;
1592
1630
  }
1593
1631
  /**
1594
1632
  * Compiles the `$select` projection of one read: each returned row is a
@@ -1616,6 +1654,7 @@ var MemoryAdapter = class MemoryAdapter extends _atscript_db.BaseDbAdapter {
1616
1654
  _readControls(controls) {
1617
1655
  return {
1618
1656
  $sort: controls.$sort,
1657
+ $nulls: controls.$nulls,
1619
1658
  $skip: controls.$skip,
1620
1659
  $limit: controls.$limit,
1621
1660
  $select: controls.$select
@@ -1652,9 +1691,9 @@ var MemoryAdapter = class MemoryAdapter extends _atscript_db.BaseDbAdapter {
1652
1691
  return (await own).filter(buildMemoryPredicate(query.filter, sets));
1653
1692
  }
1654
1693
  async findOne(query) {
1655
- const { $sort, $skip, $select } = this._readControls(query.controls ?? {});
1694
+ const { $sort, $nulls, $skip, $select } = this._readControls(query.controls ?? {});
1656
1695
  const index = $skip ?? 0;
1657
- const row = (hasSort($sort) ? this._sortRows(await this._filteredRows(query), $sort, index + 1) : await this._filteredRows(query, index + 1))[index];
1696
+ const row = (hasSort($sort) ? this._sortRows(await this._filteredRows(query), $sort, $nulls, index + 1) : await this._filteredRows(query, index + 1))[index];
1658
1697
  return row ? this._projector($select)(row) : null;
1659
1698
  }
1660
1699
  async findMany(query) {
@@ -1672,10 +1711,10 @@ var MemoryAdapter = class MemoryAdapter extends _atscript_db.BaseDbAdapter {
1672
1711
  */
1673
1712
  async findManyWithCount(query) {
1674
1713
  const filtered = await this._filteredRows(query);
1675
- const { $sort, $skip, $limit, $select } = this._readControls(query.controls ?? {});
1714
+ const { $sort, $nulls, $skip, $limit, $select } = this._readControls(query.controls ?? {});
1676
1715
  const topK = $limit === void 0 ? void 0 : ($skip ?? 0) + $limit;
1677
1716
  return {
1678
- data: paginate(this._sortRows(filtered, $sort, topK), $skip, $limit).map(this._projector($select)),
1717
+ data: paginate(this._sortRows(filtered, $sort, $nulls, topK), $skip, $limit).map(this._projector($select)),
1679
1718
  count: filtered.length
1680
1719
  };
1681
1720
  }
@@ -1709,6 +1748,10 @@ var MemoryAdapter = class MemoryAdapter extends _atscript_db.BaseDbAdapter {
1709
1748
  supportsAggregateExpressions() {
1710
1749
  return true;
1711
1750
  }
1751
+ /** NULL placement (`$nulls`, `@db.sort.nulls`) — `sortRows` honours it per key. */
1752
+ supportsNullsPlacement() {
1753
+ return true;
1754
+ }
1712
1755
  /** Managed views render nothing (non-goal) — both are accepted like `aggregateFns()`. */
1713
1756
  viewCapabilities() {
1714
1757
  return _atscript_db.ALL_VIEW_CAPABILITIES;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, ResolvedRelationFilter, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TFieldOps, TViewCapability } from "@atscript/db";
2
2
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
3
+ import { NullsPlacement } from "@uniqu/core";
3
4
 
4
5
  //#region src/lib/memory-adapter.d.ts
5
6
  /**
@@ -368,13 +369,18 @@ declare class MemoryAdapter extends BaseDbAdapter {
368
369
  updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
369
370
  deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
370
371
  /**
371
- * Stable multi-key comparator from `$sort`, with a final tie-break on
372
- * {@link pkKey} for a deterministic total order. Returns the input unchanged
372
+ * Stable multi-key comparator from `$sort`, with the primary-key fields it
373
+ * leaves out appended in the direction of its LAST key and compared by
374
+ * value (numbers numerically, `Date`s by instant) — the same tie-breaker
375
+ * the core appends before a read reaches any adapter (since 0.1.153), so a
376
+ * direct adapter call orders ties the same way. Returns the input unchanged
373
377
  * (insertion order) when there is no `$sort`. Delegates to the shared pure
374
- * {@link sortRows}, injecting {@link pkKey} as the total-order tie-break;
375
- * `topK` asks for the head of that order only (`$skip + $limit`).
378
+ * {@link sortRows}; `topK` asks for the head of that order only
379
+ * (`$skip + $limit`).
376
380
  */
377
381
  private _sortRows;
382
+ /** `$sort` plus the primary-key fields it leaves out, in its last key's direction. */
383
+ private _withPkTieBreak;
378
384
  /**
379
385
  * Compiles the `$select` projection of one read: each returned row is a
380
386
  * fresh, deep-cloned object so the store can never be mutated through it.
@@ -444,6 +450,8 @@ declare class MemoryAdapter extends BaseDbAdapter {
444
450
  aggregateFns(): ReadonlySet<AggregateFn>;
445
451
  /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
446
452
  supportsAggregateExpressions(): boolean;
453
+ /** NULL placement (`$nulls`, `@db.sort.nulls`) — `sortRows` honours it per key. */
454
+ supportsNullsPlacement(): boolean;
447
455
  /** Managed views render nothing (non-goal) — both are accepted like `aggregateFns()`. */
448
456
  viewCapabilities(): ReadonlySet<TViewCapability>;
449
457
  updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
@@ -547,20 +555,24 @@ declare function buildMemoryPredicate(filter: FilterExpr, relationSets?: Relatio
547
555
  * - No `$sort` (or an empty one) → returns the input array UNCHANGED (same
548
556
  * reference, insertion order preserved) — the fast path for an unsorted read.
549
557
  * - `tieBreak`, when supplied, is the FINAL deterministic tie-break key for a
550
- * TOTAL order — the adapter injects its {@link MemoryAdapter.pkKey} here so
551
- * rows with equal sort keys still order deterministically. When ABSENT the
558
+ * TOTAL order, so rows with equal sort keys still order deterministically
559
+ * (the adapter instead appends its primary-key fields to `$sort`, compared
560
+ * by value like every other key — since 0.1.153). When ABSENT the
552
561
  * sort falls back to preserving input order among equal keys (via each row's
553
562
  * original index), so a consumer with no primary key keeps insertion order.
554
563
  * - `topK` (since 0.1.151), when supplied, asks for only the first `topK` rows
555
564
  * of that order (e.g. `$skip + $limit`): a small one is selected in one pass
556
565
  * instead of sorting everything. The rows returned are exactly the head of
557
566
  * the full sort — same comparator, same stable tie handling.
567
+ * - `nulls` (since 0.1.153) — the `$nulls` control: a `$sort` key with an
568
+ * entry puts `null`/missing values `'first'` or `'last'` in either
569
+ * direction; a key without one keeps them smallest.
558
570
  *
559
571
  * NEVER mutates the input array. Each row's sort keys are read ONCE (O(n)) and
560
572
  * its `tieBreak` only when two rows tie on every key — not inside the
561
573
  * O(n log n) comparator.
562
574
  */
563
- declare function sortRows(rows: Record<string, unknown>[], $sort?: Partial<Record<string, 1 | -1>>, tieBreak?: (row: Record<string, unknown>) => string | number, topK?: number): Record<string, unknown>[];
575
+ declare function sortRows(rows: Record<string, unknown>[], $sort?: Partial<Record<string, 1 | -1>>, tieBreak?: (row: Record<string, unknown>) => string | number, topK?: number, nulls?: Partial<Record<string, NullsPlacement>>): Record<string, unknown>[];
564
576
  /** Options for {@link projectRow}. */
565
577
  interface ProjectRowOptions {
566
578
  /**
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, ResolvedRelationFilter, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TFieldOps, TViewCapability } from "@atscript/db";
2
+ import { NullsPlacement } from "@uniqu/core";
2
3
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
3
4
 
4
5
  //#region src/lib/memory-adapter.d.ts
@@ -368,13 +369,18 @@ declare class MemoryAdapter extends BaseDbAdapter {
368
369
  updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
369
370
  deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
370
371
  /**
371
- * Stable multi-key comparator from `$sort`, with a final tie-break on
372
- * {@link pkKey} for a deterministic total order. Returns the input unchanged
372
+ * Stable multi-key comparator from `$sort`, with the primary-key fields it
373
+ * leaves out appended in the direction of its LAST key and compared by
374
+ * value (numbers numerically, `Date`s by instant) — the same tie-breaker
375
+ * the core appends before a read reaches any adapter (since 0.1.153), so a
376
+ * direct adapter call orders ties the same way. Returns the input unchanged
373
377
  * (insertion order) when there is no `$sort`. Delegates to the shared pure
374
- * {@link sortRows}, injecting {@link pkKey} as the total-order tie-break;
375
- * `topK` asks for the head of that order only (`$skip + $limit`).
378
+ * {@link sortRows}; `topK` asks for the head of that order only
379
+ * (`$skip + $limit`).
376
380
  */
377
381
  private _sortRows;
382
+ /** `$sort` plus the primary-key fields it leaves out, in its last key's direction. */
383
+ private _withPkTieBreak;
378
384
  /**
379
385
  * Compiles the `$select` projection of one read: each returned row is a
380
386
  * fresh, deep-cloned object so the store can never be mutated through it.
@@ -444,6 +450,8 @@ declare class MemoryAdapter extends BaseDbAdapter {
444
450
  aggregateFns(): ReadonlySet<AggregateFn>;
445
451
  /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
446
452
  supportsAggregateExpressions(): boolean;
453
+ /** NULL placement (`$nulls`, `@db.sort.nulls`) — `sortRows` honours it per key. */
454
+ supportsNullsPlacement(): boolean;
447
455
  /** Managed views render nothing (non-goal) — both are accepted like `aggregateFns()`. */
448
456
  viewCapabilities(): ReadonlySet<TViewCapability>;
449
457
  updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
@@ -547,20 +555,24 @@ declare function buildMemoryPredicate(filter: FilterExpr, relationSets?: Relatio
547
555
  * - No `$sort` (or an empty one) → returns the input array UNCHANGED (same
548
556
  * reference, insertion order preserved) — the fast path for an unsorted read.
549
557
  * - `tieBreak`, when supplied, is the FINAL deterministic tie-break key for a
550
- * TOTAL order — the adapter injects its {@link MemoryAdapter.pkKey} here so
551
- * rows with equal sort keys still order deterministically. When ABSENT the
558
+ * TOTAL order, so rows with equal sort keys still order deterministically
559
+ * (the adapter instead appends its primary-key fields to `$sort`, compared
560
+ * by value like every other key — since 0.1.153). When ABSENT the
552
561
  * sort falls back to preserving input order among equal keys (via each row's
553
562
  * original index), so a consumer with no primary key keeps insertion order.
554
563
  * - `topK` (since 0.1.151), when supplied, asks for only the first `topK` rows
555
564
  * of that order (e.g. `$skip + $limit`): a small one is selected in one pass
556
565
  * instead of sorting everything. The rows returned are exactly the head of
557
566
  * the full sort — same comparator, same stable tie handling.
567
+ * - `nulls` (since 0.1.153) — the `$nulls` control: a `$sort` key with an
568
+ * entry puts `null`/missing values `'first'` or `'last'` in either
569
+ * direction; a key without one keeps them smallest.
558
570
  *
559
571
  * NEVER mutates the input array. Each row's sort keys are read ONCE (O(n)) and
560
572
  * its `tieBreak` only when two rows tie on every key — not inside the
561
573
  * O(n log n) comparator.
562
574
  */
563
- declare function sortRows(rows: Record<string, unknown>[], $sort?: Partial<Record<string, 1 | -1>>, tieBreak?: (row: Record<string, unknown>) => string | number, topK?: number): Record<string, unknown>[];
575
+ declare function sortRows(rows: Record<string, unknown>[], $sort?: Partial<Record<string, 1 | -1>>, tieBreak?: (row: Record<string, unknown>) => string | number, topK?: number, nulls?: Partial<Record<string, NullsPlacement>>): Record<string, unknown>[];
564
576
  /** Options for {@link projectRow}. */
565
577
  interface ProjectRowOptions {
566
578
  /**
package/dist/index.mjs CHANGED
@@ -427,8 +427,8 @@ function buildMemoryPredicate(filter, relationSets) {
427
427
  * there is exactly ONE implementation. Everything here is a pure function over
428
428
  * plain `Record<string, unknown>` rows — no adapter/table state — so other
429
429
  * consumers (e.g. moost-db's value-help controller) can reuse the SAME engine
430
- * instead of hand-rolling a second copy. The adapter wires its own PK-derived
431
- * tie-break / physical-PK fields in as parameters.
430
+ * instead of hand-rolling a second copy. The adapter appends its own
431
+ * physical primary-key fields to `$sort` before calling in.
432
432
  *
433
433
  * The dot-path READ / DELETE helpers (`getPath` / `deletePath`) are the
434
434
  * core's (`@atscript/db`); the dot-path WRITE helper ({@link setPath}) lives
@@ -454,6 +454,24 @@ function compareLeaves(a, b) {
454
454
  return as < bs ? -1 : as > bs ? 1 : 0;
455
455
  }
456
456
  /**
457
+ * {@link compareLeaves} with a NULL placement: `'first'` / `'last'` put
458
+ * `null`/`undefined` before / after every value, and `desc` reverses only the
459
+ * comparison of two values (since 0.1.153). Without `nulls`, nil is the
460
+ * smallest value and `desc` reverses everything, as before.
461
+ */
462
+ function compareSortKey(a, b, desc, nulls) {
463
+ if (nulls) {
464
+ const aNil = a === null || a === void 0;
465
+ const bNil = b === null || b === void 0;
466
+ if (aNil || bNil) {
467
+ if (aNil && bNil) return 0;
468
+ return (aNil ? -1 : 1) * (nulls === "first" ? 1 : -1);
469
+ }
470
+ }
471
+ const cmp = compareLeaves(a, b);
472
+ return desc ? -cmp : cmp;
473
+ }
474
+ /**
457
475
  * Dot-path setter used by inclusion projection. Creates intermediate plain
458
476
  * objects as needed; overwrites a non-object intermediate. Top-level keys and
459
477
  * nested dot-paths both work.
@@ -478,24 +496,29 @@ function setSegments(target, segments, value) {
478
496
  * - No `$sort` (or an empty one) → returns the input array UNCHANGED (same
479
497
  * reference, insertion order preserved) — the fast path for an unsorted read.
480
498
  * - `tieBreak`, when supplied, is the FINAL deterministic tie-break key for a
481
- * TOTAL order — the adapter injects its {@link MemoryAdapter.pkKey} here so
482
- * rows with equal sort keys still order deterministically. When ABSENT the
499
+ * TOTAL order, so rows with equal sort keys still order deterministically
500
+ * (the adapter instead appends its primary-key fields to `$sort`, compared
501
+ * by value like every other key — since 0.1.153). When ABSENT the
483
502
  * sort falls back to preserving input order among equal keys (via each row's
484
503
  * original index), so a consumer with no primary key keeps insertion order.
485
504
  * - `topK` (since 0.1.151), when supplied, asks for only the first `topK` rows
486
505
  * of that order (e.g. `$skip + $limit`): a small one is selected in one pass
487
506
  * instead of sorting everything. The rows returned are exactly the head of
488
507
  * the full sort — same comparator, same stable tie handling.
508
+ * - `nulls` (since 0.1.153) — the `$nulls` control: a `$sort` key with an
509
+ * entry puts `null`/missing values `'first'` or `'last'` in either
510
+ * direction; a key without one keeps them smallest.
489
511
  *
490
512
  * NEVER mutates the input array. Each row's sort keys are read ONCE (O(n)) and
491
513
  * its `tieBreak` only when two rows tie on every key — not inside the
492
514
  * O(n log n) comparator.
493
515
  */
494
- function sortRows(rows, $sort, tieBreak, topK) {
516
+ function sortRows(rows, $sort, tieBreak, topK, nulls) {
495
517
  const keys = $sort ? Object.entries($sort) : [];
496
518
  if (keys.length === 0) return rows;
497
519
  const readers = keys.map(([field]) => pathReader(field));
498
520
  const desc = keys.map(([, dir]) => dir === -1);
521
+ const placement = nulls ? keys.map(([field]) => Object.hasOwn(nulls, field) ? nulls[field] : void 0) : [];
499
522
  const decorated = rows.map((row, index) => ({
500
523
  row,
501
524
  index,
@@ -507,8 +530,8 @@ function sortRows(rows, $sort, tieBreak, topK) {
507
530
  }));
508
531
  const compare = (a, b) => {
509
532
  for (let i = 0; i < desc.length; i++) {
510
- const cmp = compareLeaves(a.keys[i], b.keys[i]);
511
- if (cmp !== 0) return desc[i] ? -cmp : cmp;
533
+ const cmp = compareSortKey(a.keys[i], b.keys[i], desc[i], placement[i]);
534
+ if (cmp !== 0) return cmp;
512
535
  }
513
536
  if (tieBreak) {
514
537
  const at = a.tie ??= tieBreak(a.row);
@@ -789,7 +812,7 @@ function aggregateRows(rows, controls) {
789
812
  const having = controls.$having;
790
813
  if (having) out = out.filter(buildMemoryPredicate(having));
791
814
  if (controls.$count) return [{ count: out.length }];
792
- const paged = paginate(sortRows(out, controls.$sort), controls.$skip, controls.$limit);
815
+ const paged = paginate(sortRows(out, controls.$sort, void 0, void 0, controls.$nulls), controls.$skip, controls.$limit);
793
816
  const fields = ($select === void 0 ? groupBy : $select.asArray ?? []).map((field) => [field, pathReader(field)]);
794
817
  const outputAliases = $select === void 0 ? [] : [...($select.buckets ?? []).map((b) => b.alias), ...$select.computedAliases];
795
818
  return paged.map((row) => {
@@ -865,18 +888,19 @@ function exprAccumulatorFactory(e) {
865
888
  /**
866
889
  * `first` / `last`: the value of `fl.column` on the group's representative
867
890
  * row — the least (`first`) or greatest (`last`) row by `rowOrder` (NULL
868
- * smallest; the primary key is the final key, so the pick is deterministic).
891
+ * smallest unless the key carries a `nulls` placement; the primary key is the final key, so the pick is deterministic).
869
892
  */
870
893
  function firstLastFactory(fl, rowOrder) {
871
894
  const keys = rowOrder.map((k) => ({
872
895
  read: pathReader(k.column),
873
- sign: k.desc ? -1 : 1
896
+ desc: k.desc,
897
+ nulls: k.nulls
874
898
  }));
875
899
  const read = pathReader(fl.column);
876
900
  const sign = fl.fn === "first" ? -1 : 1;
877
901
  const compare = (a, b) => {
878
902
  for (const k of keys) {
879
- const c = compareLeaves(k.read(a), k.read(b)) * k.sign;
903
+ const c = compareSortKey(k.read(a), k.read(b), k.desc, k.nulls);
880
904
  if (c !== 0) return c;
881
905
  }
882
906
  return 0;
@@ -1580,14 +1604,28 @@ var MemoryAdapter = class MemoryAdapter extends BaseDbAdapter {
1580
1604
  return { deletedCount: 1 };
1581
1605
  }
1582
1606
  /**
1583
- * Stable multi-key comparator from `$sort`, with a final tie-break on
1584
- * {@link pkKey} for a deterministic total order. Returns the input unchanged
1607
+ * Stable multi-key comparator from `$sort`, with the primary-key fields it
1608
+ * leaves out appended in the direction of its LAST key and compared by
1609
+ * value (numbers numerically, `Date`s by instant) — the same tie-breaker
1610
+ * the core appends before a read reaches any adapter (since 0.1.153), so a
1611
+ * direct adapter call orders ties the same way. Returns the input unchanged
1585
1612
  * (insertion order) when there is no `$sort`. Delegates to the shared pure
1586
- * {@link sortRows}, injecting {@link pkKey} as the total-order tie-break;
1587
- * `topK` asks for the head of that order only (`$skip + $limit`).
1613
+ * {@link sortRows}; `topK` asks for the head of that order only
1614
+ * (`$skip + $limit`).
1588
1615
  */
1589
- _sortRows(rows, $sort, topK) {
1590
- return sortRows(rows, $sort, (r) => this.pkKey(r), topK);
1616
+ _sortRows(rows, $sort, $nulls, topK) {
1617
+ return sortRows(rows, this._withPkTieBreak($sort), void 0, topK, $nulls);
1618
+ }
1619
+ /** `$sort` plus the primary-key fields it leaves out, in its last key's direction. */
1620
+ _withPkTieBreak($sort) {
1621
+ if (!$sort || !hasSort($sort)) return $sort;
1622
+ const keys = Object.keys($sort);
1623
+ const missing = this._physicalPkFields().filter((field) => !(field in $sort));
1624
+ if (missing.length === 0) return $sort;
1625
+ const dir = $sort[keys[keys.length - 1]];
1626
+ const next = { ...$sort };
1627
+ for (const field of missing) next[field] = dir;
1628
+ return next;
1591
1629
  }
1592
1630
  /**
1593
1631
  * Compiles the `$select` projection of one read: each returned row is a
@@ -1615,6 +1653,7 @@ var MemoryAdapter = class MemoryAdapter extends BaseDbAdapter {
1615
1653
  _readControls(controls) {
1616
1654
  return {
1617
1655
  $sort: controls.$sort,
1656
+ $nulls: controls.$nulls,
1618
1657
  $skip: controls.$skip,
1619
1658
  $limit: controls.$limit,
1620
1659
  $select: controls.$select
@@ -1651,9 +1690,9 @@ var MemoryAdapter = class MemoryAdapter extends BaseDbAdapter {
1651
1690
  return (await own).filter(buildMemoryPredicate(query.filter, sets));
1652
1691
  }
1653
1692
  async findOne(query) {
1654
- const { $sort, $skip, $select } = this._readControls(query.controls ?? {});
1693
+ const { $sort, $nulls, $skip, $select } = this._readControls(query.controls ?? {});
1655
1694
  const index = $skip ?? 0;
1656
- const row = (hasSort($sort) ? this._sortRows(await this._filteredRows(query), $sort, index + 1) : await this._filteredRows(query, index + 1))[index];
1695
+ const row = (hasSort($sort) ? this._sortRows(await this._filteredRows(query), $sort, $nulls, index + 1) : await this._filteredRows(query, index + 1))[index];
1657
1696
  return row ? this._projector($select)(row) : null;
1658
1697
  }
1659
1698
  async findMany(query) {
@@ -1671,10 +1710,10 @@ var MemoryAdapter = class MemoryAdapter extends BaseDbAdapter {
1671
1710
  */
1672
1711
  async findManyWithCount(query) {
1673
1712
  const filtered = await this._filteredRows(query);
1674
- const { $sort, $skip, $limit, $select } = this._readControls(query.controls ?? {});
1713
+ const { $sort, $nulls, $skip, $limit, $select } = this._readControls(query.controls ?? {});
1675
1714
  const topK = $limit === void 0 ? void 0 : ($skip ?? 0) + $limit;
1676
1715
  return {
1677
- data: paginate(this._sortRows(filtered, $sort, topK), $skip, $limit).map(this._projector($select)),
1716
+ data: paginate(this._sortRows(filtered, $sort, $nulls, topK), $skip, $limit).map(this._projector($select)),
1678
1717
  count: filtered.length
1679
1718
  };
1680
1719
  }
@@ -1708,6 +1747,10 @@ var MemoryAdapter = class MemoryAdapter extends BaseDbAdapter {
1708
1747
  supportsAggregateExpressions() {
1709
1748
  return true;
1710
1749
  }
1750
+ /** NULL placement (`$nulls`, `@db.sort.nulls`) — `sortRows` honours it per key. */
1751
+ supportsNullsPlacement() {
1752
+ return true;
1753
+ }
1711
1754
  /** Managed views render nothing (non-goal) — both are accepted like `aggregateFns()`. */
1712
1755
  viewCapabilities() {
1713
1756
  return ALL_VIEW_CAPABILITIES;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-memory",
3
- "version": "0.1.152",
3
+ "version": "0.1.154",
4
4
  "description": "In-memory adapter for atscript-db.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -36,16 +36,16 @@
36
36
  "access": "public"
37
37
  },
38
38
  "devDependencies": {
39
- "@atscript/core": "^0.1.102",
40
- "@atscript/typescript": "^0.1.102",
41
- "@uniqu/core": "^0.1.14",
42
- "unplugin-atscript": "^0.1.102"
39
+ "@atscript/core": "^0.1.103",
40
+ "@atscript/typescript": "^0.1.103",
41
+ "@uniqu/core": "^0.1.15",
42
+ "unplugin-atscript": "^0.1.103"
43
43
  },
44
44
  "peerDependencies": {
45
- "@atscript/core": "^0.1.102",
46
- "@atscript/typescript": "^0.1.102",
47
- "@uniqu/core": "^0.1.14",
48
- "@atscript/db": "^0.1.152"
45
+ "@atscript/core": "^0.1.103",
46
+ "@atscript/typescript": "^0.1.103",
47
+ "@uniqu/core": "^0.1.15",
48
+ "@atscript/db": "^0.1.154"
49
49
  },
50
50
  "scripts": {
51
51
  "postinstall": "asc -f dts",