@aouda/client 0.1.13 → 0.1.15

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.
@@ -421,7 +421,7 @@ var import_node_url = require("url");
421
421
  // package.json
422
422
  var package_default = {
423
423
  name: "@aouda/client",
424
- version: "0.1.13",
424
+ version: "0.1.15",
425
425
  description: "Official TypeScript/JavaScript client library for Aouda",
426
426
  type: "module",
427
427
  main: "./dist/index.cjs",
@@ -536,43 +536,44 @@ var AoudaResponseError = class extends AoudaError {
536
536
  }
537
537
  };
538
538
  var AoudaApiError = class extends AoudaError {
539
- constructor(message, code, statusCode, details, requestId, retryAfterSeconds) {
539
+ constructor(message, code, statusCode, details, requestId, retryAfterSeconds, token) {
540
540
  super(message);
541
541
  this.code = code;
542
542
  this.statusCode = statusCode;
543
543
  this.details = details;
544
544
  this.requestId = requestId;
545
545
  this.retryAfterSeconds = retryAfterSeconds;
546
+ this.token = token;
546
547
  this.name = "AoudaApiError";
547
548
  }
548
549
  };
549
550
  var AoudaNotFoundError = class extends AoudaApiError {
550
- constructor(message, code, statusCode, details, requestId) {
551
- super(message, code, statusCode, details, requestId);
551
+ constructor(message, code, statusCode, details, requestId, retryAfterSeconds, token) {
552
+ super(message, code, statusCode, details, requestId, retryAfterSeconds, token);
552
553
  this.name = "AoudaNotFoundError";
553
554
  }
554
555
  };
555
556
  var AoudaConflictError = class extends AoudaApiError {
556
- constructor(message, code, statusCode, details, requestId) {
557
- super(message, code, statusCode, details, requestId);
557
+ constructor(message, code, statusCode, details, requestId, retryAfterSeconds, token) {
558
+ super(message, code, statusCode, details, requestId, retryAfterSeconds, token);
558
559
  this.name = "AoudaConflictError";
559
560
  }
560
561
  };
561
562
  var AoudaValidationError = class extends AoudaApiError {
562
- constructor(message, code, statusCode, details, requestId) {
563
- super(message, code, statusCode, details, requestId);
563
+ constructor(message, code, statusCode, details, requestId, retryAfterSeconds, token) {
564
+ super(message, code, statusCode, details, requestId, retryAfterSeconds, token);
564
565
  this.name = "AoudaValidationError";
565
566
  }
566
567
  };
567
568
  var AoudaServerError = class extends AoudaApiError {
568
- constructor(message, code, statusCode, details, requestId, retryAfterSeconds) {
569
- super(message, code, statusCode, details, requestId, retryAfterSeconds);
569
+ constructor(message, code, statusCode, details, requestId, retryAfterSeconds, token) {
570
+ super(message, code, statusCode, details, requestId, retryAfterSeconds, token);
570
571
  this.name = "AoudaServerError";
571
572
  }
572
573
  };
573
574
  var AoudaAuthenticationError = class extends AoudaApiError {
574
- constructor(message, code, statusCode, details, requestId) {
575
- super(message, code, statusCode, details, requestId);
575
+ constructor(message, code, statusCode, details, requestId, retryAfterSeconds, token) {
576
+ super(message, code, statusCode, details, requestId, retryAfterSeconds, token);
576
577
  this.name = "AoudaAuthenticationError";
577
578
  }
578
579
  };
@@ -1368,7 +1369,7 @@ function buildHandle(jobId, commitResp, wasResumed, transport, database, signal)
1368
1369
  rowsDurablyCommitted: commitResp.rowsLoaded,
1369
1370
  segmentsCreated: commitResp.segmentsCreated,
1370
1371
  committedAtUtc: commitResp.committedAtUtc,
1371
- walPosition: commitResp.walPosition,
1372
+ token: commitResp.token,
1372
1373
  writeConcernSatisfied: commitResp.writeConcernAchieved ?? "acknowledged",
1373
1374
  writeConcernTimedOut: commitResp.writeConcernTimedOut,
1374
1375
  wasResumed,
@@ -1494,6 +1495,7 @@ var PROTOCOL_VERSION_HEADER = "X-Aouda-Protocol-Version";
1494
1495
  var PROTOCOL_VERSION = "1";
1495
1496
  var CONTENT_TYPE_JSON2 = "application/json";
1496
1497
  var REQUEST_ID_HEADER2 = "X-Request-Id";
1498
+ var TOKEN_HEADER = "X-Aouda-Token";
1497
1499
  var ERROR_CODE_MAP = {
1498
1500
  TABLE_NOT_FOUND: AoudaNotFoundError,
1499
1501
  COLUMN_NOT_FOUND: AoudaNotFoundError,
@@ -1533,7 +1535,12 @@ var ERROR_CODE_MAP = {
1533
1535
  AUTH_IDENTITY_INVALID: AoudaValidationError,
1534
1536
  AUTH_IDENTITY_NOT_FOUND: AoudaValidationError,
1535
1537
  BULK_LOAD_TRANSFORM_INTENT_REQUIRED: AoudaValidationError,
1536
- BULK_LOAD_TRANSFORM_INTENT_CONFLICT: AoudaValidationError
1538
+ BULK_LOAD_TRANSFORM_INTENT_CONFLICT: AoudaValidationError,
1539
+ TOKEN_MALFORMED: AoudaValidationError,
1540
+ TOKEN_FOREIGN_DATABASE: AoudaValidationError,
1541
+ TOKEN_EPOCH_SUPERSEDED: AoudaConflictError,
1542
+ TOKEN_UNSATISFIED: AoudaConflictError,
1543
+ TOKEN_FETCH_PRIMARY: AoudaApiError
1537
1544
  };
1538
1545
  function createComposedAbortController(...signals) {
1539
1546
  const controller = new AbortController();
@@ -1570,7 +1577,7 @@ function createApiError(statusCode, statusText, body, retryAfterHeader) {
1570
1577
  const requestId = body.requestId;
1571
1578
  const retryAfterSeconds = parseRetryAfterSeconds(retryAfterHeader ?? null);
1572
1579
  const Ctor = ERROR_CODE_MAP[code] ?? AoudaApiError;
1573
- return new Ctor(message, code, statusCode, details, requestId, retryAfterSeconds);
1580
+ return new Ctor(message, code, statusCode, details, requestId, retryAfterSeconds, body.token);
1574
1581
  }
1575
1582
  var HttpTransport = class {
1576
1583
  constructor(options) {
@@ -1582,6 +1589,29 @@ var HttpTransport = class {
1582
1589
  ...options.defaultHeaders
1583
1590
  };
1584
1591
  this.abortController = new AbortController();
1592
+ this.database = options.database;
1593
+ this.store = options.consistencyTokenStore;
1594
+ }
1595
+ presentTokenHeaders(path4, headers) {
1596
+ if (this.store == null || this.database == null || isAuthPath(path4)) {
1597
+ return;
1598
+ }
1599
+ const token = this.store.get(this.database);
1600
+ if (token != null && token.length > 0) {
1601
+ headers[TOKEN_HEADER] = token;
1602
+ }
1603
+ }
1604
+ observeResponse(_path, response, bodyText, errorCode) {
1605
+ if (this.store == null || this.database == null) {
1606
+ return;
1607
+ }
1608
+ if (errorCode === "TOKEN_MALFORMED" || errorCode === "TOKEN_FOREIGN_DATABASE") {
1609
+ return;
1610
+ }
1611
+ const header = response.headers.get(TOKEN_HEADER);
1612
+ const bodyToken = readBodyToken(bodyText);
1613
+ const token = header != null && header.trim().length > 0 ? header : bodyToken;
1614
+ this.store.observe(this.database, token);
1585
1615
  }
1586
1616
  /**
1587
1617
  * Abort all in-flight and future requests (e.g. on client disconnect).
@@ -1619,6 +1649,7 @@ var HttpTransport = class {
1619
1649
  ...this.defaultHeaders,
1620
1650
  ...config.headers
1621
1651
  };
1652
+ this.presentTokenHeaders(config.path, headers);
1622
1653
  if (config.body !== void 0 && config.body !== null) {
1623
1654
  headers["Content-Type"] = CONTENT_TYPE_JSON2;
1624
1655
  }
@@ -1638,6 +1669,7 @@ var HttpTransport = class {
1638
1669
  const text = await response.text();
1639
1670
  if (config.allowStatuses?.includes(response.status) && text) {
1640
1671
  try {
1672
+ this.observeResponse(config.path, response, text);
1641
1673
  return JSON.parse(text);
1642
1674
  } catch {
1643
1675
  }
@@ -1651,15 +1683,19 @@ var HttpTransport = class {
1651
1683
  }
1652
1684
  if (response.status === 401) {
1653
1685
  const code = errorBody?.code ?? "AUTH_TOKEN_MISSING";
1686
+ this.observeResponse(config.path, response, text, code);
1654
1687
  throw new AoudaAuthenticationError(
1655
1688
  errorBody?.error ?? "Unauthorized",
1656
1689
  code,
1657
1690
  401,
1658
1691
  errorBody?.details,
1659
- errorBody?.requestId
1692
+ errorBody?.requestId,
1693
+ void 0,
1694
+ errorBody?.token
1660
1695
  );
1661
1696
  }
1662
1697
  if (errorBody?.code != null) {
1698
+ this.observeResponse(config.path, response, text, errorBody.code);
1663
1699
  throw createApiError(
1664
1700
  response.status,
1665
1701
  response.statusText,
@@ -1667,6 +1703,7 @@ var HttpTransport = class {
1667
1703
  response.headers.get("Retry-After")
1668
1704
  );
1669
1705
  }
1706
+ this.observeResponse(config.path, response, text);
1670
1707
  throw new AoudaResponseError(
1671
1708
  errorBody?.error ?? `${response.status} ${response.statusText}`,
1672
1709
  response.status,
@@ -1674,6 +1711,7 @@ var HttpTransport = class {
1674
1711
  );
1675
1712
  }
1676
1713
  const responseText = await response.text();
1714
+ this.observeResponse(config.path, response, responseText);
1677
1715
  if (config.rawText) {
1678
1716
  return responseText;
1679
1717
  }
@@ -1734,6 +1772,7 @@ var HttpTransport = class {
1734
1772
  ...this.defaultHeaders,
1735
1773
  ...config.headers
1736
1774
  };
1775
+ this.presentTokenHeaders(config.path, headers);
1737
1776
  if (config.rawBodyStr === void 0 && config.body !== void 0 && config.body !== null) {
1738
1777
  headers["Content-Type"] = CONTENT_TYPE_JSON2;
1739
1778
  }
@@ -1750,6 +1789,7 @@ var HttpTransport = class {
1750
1789
  try {
1751
1790
  const response = await localNetworkFetch(url, init);
1752
1791
  if (response.ok || config.allowStatuses?.includes(response.status)) {
1792
+ this.observeResponse(config.path, response, void 0);
1753
1793
  return response;
1754
1794
  }
1755
1795
  const text = await response.text();
@@ -1762,15 +1802,19 @@ var HttpTransport = class {
1762
1802
  }
1763
1803
  if (response.status === 401) {
1764
1804
  const code = errorBody?.code ?? "AUTH_TOKEN_MISSING";
1805
+ this.observeResponse(config.path, response, text, code);
1765
1806
  throw new AoudaAuthenticationError(
1766
1807
  errorBody?.error ?? "Unauthorized",
1767
1808
  code,
1768
1809
  401,
1769
1810
  errorBody?.details,
1770
- errorBody?.requestId
1811
+ errorBody?.requestId,
1812
+ void 0,
1813
+ errorBody?.token
1771
1814
  );
1772
1815
  }
1773
1816
  if (errorBody?.code != null) {
1817
+ this.observeResponse(config.path, response, text, errorBody.code);
1774
1818
  throw createApiError(
1775
1819
  response.status,
1776
1820
  response.statusText,
@@ -1778,6 +1822,7 @@ var HttpTransport = class {
1778
1822
  response.headers.get("Retry-After")
1779
1823
  );
1780
1824
  }
1825
+ this.observeResponse(config.path, response, text);
1781
1826
  throw new AoudaResponseError(
1782
1827
  errorBody?.error ?? `${response.status} ${response.statusText}`,
1783
1828
  response.status,
@@ -1932,6 +1977,47 @@ var HttpTransport = class {
1932
1977
  });
1933
1978
  }
1934
1979
  };
1980
+ function isAuthPath(path4) {
1981
+ let p = path4;
1982
+ const q = p.indexOf("?");
1983
+ if (q >= 0) {
1984
+ p = p.slice(0, q);
1985
+ }
1986
+ if (/^https?:\/\//i.test(p)) {
1987
+ try {
1988
+ p = new URL(p).pathname;
1989
+ } catch {
1990
+ }
1991
+ }
1992
+ const lower = p.toLowerCase();
1993
+ if (lower === "/api/auth" || lower.startsWith("/api/auth/")) {
1994
+ return true;
1995
+ }
1996
+ const prefix = "/api/databases/";
1997
+ if (!lower.startsWith(prefix)) {
1998
+ return false;
1999
+ }
2000
+ const rest = lower.slice(prefix.length);
2001
+ const slash = rest.indexOf("/");
2002
+ if (slash < 0) {
2003
+ return false;
2004
+ }
2005
+ const afterDb = rest.slice(slash + 1);
2006
+ return afterDb === "auth" || afterDb.startsWith("auth/");
2007
+ }
2008
+ function readBodyToken(bodyText) {
2009
+ if (bodyText == null || bodyText.trim().length === 0) {
2010
+ return void 0;
2011
+ }
2012
+ try {
2013
+ const parsed = JSON.parse(bodyText);
2014
+ if (parsed !== null && typeof parsed === "object" && "token" in parsed && typeof parsed.token === "string") {
2015
+ return parsed.token;
2016
+ }
2017
+ } catch {
2018
+ }
2019
+ return void 0;
2020
+ }
1935
2021
 
1936
2022
  // src/resilience/retry.ts
1937
2023
  var DEFAULT_MAX_RETRIES = 3;
@@ -2364,6 +2450,48 @@ function databasePath2(db) {
2364
2450
  return `/api/databases/${encodeURIComponent(db)}`;
2365
2451
  }
2366
2452
 
2453
+ // src/consistency-token-store.ts
2454
+ var MemoryConsistencyTokenStore = class {
2455
+ constructor() {
2456
+ this.tokens = /* @__PURE__ */ new Map();
2457
+ }
2458
+ get(database) {
2459
+ if (typeof database !== "string" || database.trim().length === 0) {
2460
+ throw new Error("database must be a non-empty string");
2461
+ }
2462
+ return this.tokens.get(normalizeKey(database));
2463
+ }
2464
+ observe(database, token) {
2465
+ if (typeof database !== "string" || database.trim().length === 0) {
2466
+ throw new Error("database must be a non-empty string");
2467
+ }
2468
+ if (token == null || token.trim().length === 0) {
2469
+ return;
2470
+ }
2471
+ const key = normalizeKey(database);
2472
+ const stored = this.tokens.get(key);
2473
+ if (stored === void 0 || compareOrdinal(token, stored) > 0) {
2474
+ this.tokens.set(key, token);
2475
+ }
2476
+ }
2477
+ };
2478
+ function compareOrdinal(left, right) {
2479
+ if (left === right) return 0;
2480
+ return left < right ? -1 : 1;
2481
+ }
2482
+ function maxToken(left, right) {
2483
+ if (left == null || left.length === 0) {
2484
+ return right == null || right.length === 0 ? void 0 : right;
2485
+ }
2486
+ if (right == null || right.length === 0) {
2487
+ return left;
2488
+ }
2489
+ return compareOrdinal(left, right) >= 0 ? left : right;
2490
+ }
2491
+ function normalizeKey(database) {
2492
+ return database.toLowerCase();
2493
+ }
2494
+
2367
2495
  // src/streaming/subscription.ts
2368
2496
  var AsyncEventQueue = class {
2369
2497
  constructor() {
@@ -2420,7 +2548,7 @@ var AsyncEventQueue = class {
2420
2548
  }
2421
2549
  };
2422
2550
  var TableSubscription = class {
2423
- constructor(transport, identity, options = {}, onWarnings) {
2551
+ constructor(transport, identity, options = {}, onWarnings, store, database) {
2424
2552
  this._queue = new AsyncEventQueue();
2425
2553
  this._active = true;
2426
2554
  this._started = false;
@@ -2436,6 +2564,11 @@ var TableSubscription = class {
2436
2564
  this._onError = options.onError;
2437
2565
  this._onWarnings = onWarnings;
2438
2566
  this._conflate = options.conflate;
2567
+ this._atLeast = options.atLeast;
2568
+ this._waitMs = options.waitMs;
2569
+ this._onExceeded = options.onExceeded;
2570
+ this._store = store;
2571
+ this._database = database;
2439
2572
  this._reconnectHandlerKey = `${this.id}::reconnect`;
2440
2573
  }
2441
2574
  get lastVersion() {
@@ -2500,10 +2633,13 @@ var TableSubscription = class {
2500
2633
  id: this.id
2501
2634
  };
2502
2635
  if (this._identity.kind === "named") {
2503
- message.hash = this._identity.hash;
2636
+ message.name = this._identity.name;
2504
2637
  if (this._identity.args !== void 0) {
2505
2638
  message.args = this._identity.args;
2506
2639
  }
2640
+ if (this._identity.orderByIndex !== void 0) {
2641
+ message.orderByIndex = this._identity.orderByIndex;
2642
+ }
2507
2643
  } else {
2508
2644
  message.target = this._identity.target;
2509
2645
  if (this._identity.filter !== void 0) {
@@ -2516,8 +2652,30 @@ var TableSubscription = class {
2516
2652
  if (this._conflate !== void 0) {
2517
2653
  message.conflate = this._conflate;
2518
2654
  }
2655
+ const pin = this._resolvePin();
2656
+ if (pin !== void 0) {
2657
+ message.at_least = pin;
2658
+ }
2659
+ if (this._waitMs !== void 0) {
2660
+ message.wait_ms = this._waitMs;
2661
+ }
2662
+ if (this._onExceeded !== void 0) {
2663
+ message.on_exceeded = this._onExceeded;
2664
+ }
2519
2665
  await this._transport.send(message);
2520
2666
  }
2667
+ _resolvePin() {
2668
+ if (this._atLeast !== void 0 && this._store != null && this._database != null) {
2669
+ this._store.observe(this._database, this._atLeast);
2670
+ }
2671
+ const stored = this._store != null && this._database != null ? this._store.get(this._database) : void 0;
2672
+ return maxToken(this._atLeast, stored);
2673
+ }
2674
+ _observeToken(token) {
2675
+ if (token !== void 0 && this._store != null && this._database != null) {
2676
+ this._store.observe(this._database, token);
2677
+ }
2678
+ }
2521
2679
  _handleMessage(message) {
2522
2680
  if (!this._active) {
2523
2681
  return;
@@ -2544,6 +2702,7 @@ var TableSubscription = class {
2544
2702
  }
2545
2703
  _handleSnapshotPage(message) {
2546
2704
  this._pendingSnapshotRows.push(...message.rows);
2705
+ this._observeToken(message.token);
2547
2706
  }
2548
2707
  _handleSnapshotComplete(message) {
2549
2708
  this._lastVersion = message.version;
@@ -2552,12 +2711,20 @@ var TableSubscription = class {
2552
2711
  }
2553
2712
  const rows = this._pendingSnapshotRows;
2554
2713
  this._pendingSnapshotRows = [];
2555
- this._onSnapshot?.(rows, message.version);
2556
- this._queue.push({
2714
+ this._onSnapshot?.(rows, message.version, message.total_matches);
2715
+ const snapshot = {
2557
2716
  type: "snapshot",
2558
2717
  rows,
2559
2718
  version: message.version
2560
- });
2719
+ };
2720
+ if (message.total_matches !== void 0) {
2721
+ snapshot.totalMatches = message.total_matches;
2722
+ }
2723
+ if (message.token !== void 0) {
2724
+ snapshot.token = message.token;
2725
+ this._observeToken(message.token);
2726
+ }
2727
+ this._queue.push(snapshot);
2561
2728
  }
2562
2729
  async _handleGap(message) {
2563
2730
  this._pendingSnapshotRows = [];
@@ -2582,6 +2749,10 @@ var TableSubscription = class {
2582
2749
  if (message.values_skipped !== void 0) {
2583
2750
  event.values_skipped = message.values_skipped;
2584
2751
  }
2752
+ if (message.token !== void 0) {
2753
+ event.token = message.token;
2754
+ this._observeToken(message.token);
2755
+ }
2585
2756
  this._onChange?.(event);
2586
2757
  this._queue.push(event);
2587
2758
  }
@@ -2926,11 +3097,12 @@ var TableQuery = class _TableQuery {
2926
3097
  * @param state - Optional initial state (used for immutable chaining).
2927
3098
  * @internal Use `client.table()` to create queries.
2928
3099
  */
2929
- constructor(transport, tableName, database, state, getWebSocketTransport) {
3100
+ constructor(transport, tableName, database, state, getWebSocketTransport, store) {
2930
3101
  this.transport = transport;
2931
3102
  this.tableName = tableName;
2932
3103
  this.database = database;
2933
3104
  this.getWebSocketTransport = getWebSocketTransport;
3105
+ this.store = store;
2934
3106
  this.state = state ?? {
2935
3107
  predicates: [],
2936
3108
  groupClauses: [],
@@ -2946,6 +3118,16 @@ var TableQuery = class _TableQuery {
2946
3118
  isDistinct: false
2947
3119
  };
2948
3120
  }
3121
+ withState(state) {
3122
+ return new _TableQuery(
3123
+ this.transport,
3124
+ this.tableName,
3125
+ this.database,
3126
+ state,
3127
+ this.getWebSocketTransport,
3128
+ this.store
3129
+ );
3130
+ }
2949
3131
  where(column, operator, value) {
2950
3132
  const newPredicates = buildWherePredicates(column, operator, value);
2951
3133
  return new _TableQuery(
@@ -2956,7 +3138,8 @@ var TableQuery = class _TableQuery {
2956
3138
  ...this.state,
2957
3139
  predicates: [...this.state.predicates, ...newPredicates]
2958
3140
  },
2959
- this.getWebSocketTransport
3141
+ this.getWebSocketTransport,
3142
+ this.store
2960
3143
  );
2961
3144
  }
2962
3145
  /**
@@ -2975,7 +3158,7 @@ var TableQuery = class _TableQuery {
2975
3158
  return new _TableQuery(this.transport, this.tableName, this.database, {
2976
3159
  ...this.state,
2977
3160
  groupClauses: [...this.state.groupClauses, sub]
2978
- }, this.getWebSocketTransport);
3161
+ }, this.getWebSocketTransport, this.store);
2979
3162
  }
2980
3163
  /**
2981
3164
  * Sets the primary sort column for the query.
@@ -3000,7 +3183,7 @@ var TableQuery = class _TableQuery {
3000
3183
  return new _TableQuery(this.transport, this.tableName, this.database, {
3001
3184
  ...this.state,
3002
3185
  orderByClauses: [orderByClause]
3003
- }, this.getWebSocketTransport);
3186
+ }, this.getWebSocketTransport, this.store);
3004
3187
  }
3005
3188
  /**
3006
3189
  * Sets the primary sort column to descending order.
@@ -3046,7 +3229,7 @@ var TableQuery = class _TableQuery {
3046
3229
  return new _TableQuery(this.transport, this.tableName, this.database, {
3047
3230
  ...this.state,
3048
3231
  orderByClauses: [...this.state.orderByClauses, orderByClause]
3049
- }, this.getWebSocketTransport);
3232
+ }, this.getWebSocketTransport, this.store);
3050
3233
  }
3051
3234
  /**
3052
3235
  * Sets the maximum number of rows to return.
@@ -3063,7 +3246,7 @@ var TableQuery = class _TableQuery {
3063
3246
  return new _TableQuery(this.transport, this.tableName, this.database, {
3064
3247
  ...this.state,
3065
3248
  limitValue: count
3066
- }, this.getWebSocketTransport);
3249
+ }, this.getWebSocketTransport, this.store);
3067
3250
  }
3068
3251
  /**
3069
3252
  * Sets the number of rows to skip.
@@ -3080,7 +3263,7 @@ var TableQuery = class _TableQuery {
3080
3263
  return new _TableQuery(this.transport, this.tableName, this.database, {
3081
3264
  ...this.state,
3082
3265
  offsetValue: count
3083
- }, this.getWebSocketTransport);
3266
+ }, this.getWebSocketTransport, this.store);
3084
3267
  }
3085
3268
  /**
3086
3269
  * Requests cross-partition access for this query.
@@ -3095,10 +3278,21 @@ var TableQuery = class _TableQuery {
3095
3278
  return new _TableQuery(this.transport, this.tableName, this.database, {
3096
3279
  ...this.state,
3097
3280
  crossPartitionAccess: true
3098
- }, this.getWebSocketTransport);
3281
+ }, this.getWebSocketTransport, this.store);
3282
+ }
3283
+ /**
3284
+ * Pin this query at at least this C-1 token. Observes the token into the
3285
+ * client store (I3, sticky) and presents it on execute via `X-Aouda-Token`.
3286
+ */
3287
+ atLeast(token) {
3288
+ if (typeof token !== "string" || token.trim().length === 0) {
3289
+ throw new Error("atLeast() requires a non-empty token");
3290
+ }
3291
+ this.store?.observe(this.database, token);
3292
+ return this.withState({ ...this.state, atLeast: token });
3099
3293
  }
3100
3294
  /**
3101
- * Restricts the columns returned in the result.
3295
+ * Selects specific columns to return.
3102
3296
  * If not called, all columns are returned.
3103
3297
  *
3104
3298
  * When T is a specific row type, only keys of T are accepted as column names.
@@ -3120,7 +3314,7 @@ var TableQuery = class _TableQuery {
3120
3314
  return new _TableQuery(this.transport, this.tableName, this.database, {
3121
3315
  ...this.state,
3122
3316
  selectColumns: columns.length > 0 ? columns : null
3123
- }, this.getWebSocketTransport);
3317
+ }, this.getWebSocketTransport, this.store);
3124
3318
  }
3125
3319
  /**
3126
3320
  * Return only distinct (de-duplicated) rows for the given columns — SQL `SELECT DISTINCT`.
@@ -3150,7 +3344,7 @@ var TableQuery = class _TableQuery {
3150
3344
  ...this.state,
3151
3345
  selectColumns: columns,
3152
3346
  isDistinct: true
3153
- }, this.getWebSocketTransport);
3347
+ }, this.getWebSocketTransport, this.store);
3154
3348
  }
3155
3349
  /**
3156
3350
  * Adds server-side computed columns to the query result.
@@ -3179,7 +3373,7 @@ var TableQuery = class _TableQuery {
3179
3373
  return new _TableQuery(this.transport, this.tableName, this.database, {
3180
3374
  ...this.state,
3181
3375
  selectExprs: projections
3182
- }, this.getWebSocketTransport);
3376
+ }, this.getWebSocketTransport, this.store);
3183
3377
  }
3184
3378
  join(rightTable, leftColumnOrColumns, rightColumnOrColumns) {
3185
3379
  return this.addJoinClause(
@@ -3237,7 +3431,8 @@ var TableQuery = class _TableQuery {
3237
3431
  }
3238
3432
  ]
3239
3433
  },
3240
- this.getWebSocketTransport
3434
+ this.getWebSocketTransport,
3435
+ this.store
3241
3436
  );
3242
3437
  }
3243
3438
  /**
@@ -3273,7 +3468,8 @@ var TableQuery = class _TableQuery {
3273
3468
  ...this.state,
3274
3469
  groupByColumns: [...columns]
3275
3470
  },
3276
- this.getWebSocketTransport
3471
+ this.getWebSocketTransport,
3472
+ this.store
3277
3473
  );
3278
3474
  }
3279
3475
  /**
@@ -3307,8 +3503,11 @@ var TableQuery = class _TableQuery {
3307
3503
  onSnapshot: options.onSnapshot,
3308
3504
  onChange: options.onChange,
3309
3505
  onError: options.onError,
3310
- conflate: options.conflate
3311
- });
3506
+ conflate: options.conflate,
3507
+ atLeast: options.atLeast ?? this.state.atLeast,
3508
+ waitMs: options.waitMs,
3509
+ onExceeded: options.onExceeded
3510
+ }, void 0, this.store, this.database);
3312
3511
  subscription.start();
3313
3512
  return subscription;
3314
3513
  }
@@ -3423,7 +3622,8 @@ var TableQuery = class _TableQuery {
3423
3622
  ...this.state,
3424
3623
  joinClauses: [...this.state.joinClauses, joinClause]
3425
3624
  },
3426
- this.getWebSocketTransport
3625
+ this.getWebSocketTransport,
3626
+ this.store
3427
3627
  );
3428
3628
  }
3429
3629
  addAggregate(op, column) {
@@ -3444,7 +3644,8 @@ var TableQuery = class _TableQuery {
3444
3644
  }
3445
3645
  ]
3446
3646
  },
3447
- this.getWebSocketTransport
3647
+ this.getWebSocketTransport,
3648
+ this.store
3448
3649
  );
3449
3650
  }
3450
3651
  requireWebSocketTransport() {
@@ -3474,17 +3675,23 @@ var TableQuery = class _TableQuery {
3474
3675
  * ```
3475
3676
  */
3476
3677
  async execute() {
3678
+ if (this.state.atLeast) {
3679
+ this.store?.observe(this.database, this.state.atLeast);
3680
+ }
3477
3681
  const request = this.buildRequest();
3478
3682
  const path4 = `${databasePath2(this.database)}/query`;
3479
3683
  const response = await this.transport.post(path4, request);
3480
3684
  const rows = columnarToRows(response);
3481
3685
  const stats = response.stats;
3482
- return { rows, stats };
3686
+ return { rows, stats, token: response.token };
3483
3687
  }
3484
3688
  /**
3485
3689
  * Executes the query and returns the raw columnar JSON payload (no row-object conversion).
3486
3690
  */
3487
3691
  async toColumnar() {
3692
+ if (this.state.atLeast) {
3693
+ this.store?.observe(this.database, this.state.atLeast);
3694
+ }
3488
3695
  const request = this.buildRequest();
3489
3696
  const path4 = `${databasePath2(this.database)}/query`;
3490
3697
  return this.transport.post(path4, request);
@@ -3514,8 +3721,12 @@ var TableQuery = class _TableQuery {
3514
3721
  limitValue: 0,
3515
3722
  selectColumns: []
3516
3723
  },
3517
- this.getWebSocketTransport
3724
+ this.getWebSocketTransport,
3725
+ this.store
3518
3726
  );
3727
+ if (this.state.atLeast) {
3728
+ this.store?.observe(this.database, this.state.atLeast);
3729
+ }
3519
3730
  const request = countQuery.buildRequest();
3520
3731
  const path4 = `${databasePath2(this.database)}/query`;
3521
3732
  const response = await this.transport.post(path4, request);
@@ -3562,7 +3773,8 @@ var TableQuery = class _TableQuery {
3562
3773
  const response = await this.transport.post(path4, body);
3563
3774
  const result = {
3564
3775
  rowsInserted: response.rowsInserted,
3565
- executionMs: response.executionMs
3776
+ executionMs: response.executionMs,
3777
+ token: response.token
3566
3778
  };
3567
3779
  if (response.generatedValues !== void 0) {
3568
3780
  result.generatedValues = response.generatedValues;
@@ -3612,7 +3824,8 @@ var TableQuery = class _TableQuery {
3612
3824
  const response = await this.transport.post(path4, body);
3613
3825
  const result = {
3614
3826
  rowsInserted: response.rowsInserted,
3615
- executionMs: response.executionMs
3827
+ executionMs: response.executionMs,
3828
+ token: response.token
3616
3829
  };
3617
3830
  if (response.generatedValues !== void 0) {
3618
3831
  result.generatedValues = response.generatedValues;
@@ -3664,6 +3877,7 @@ var TableQuery = class _TableQuery {
3664
3877
  return {
3665
3878
  rowsAffected: response.rowsUpdated,
3666
3879
  executionMs: response.executionMs,
3880
+ token: response.token,
3667
3881
  ...response.rows ? { rows: columnarToRows(response.rows) } : {},
3668
3882
  ...response.rowsTruncated ? { rowsTruncated: true } : {}
3669
3883
  };
@@ -3714,6 +3928,7 @@ var TableQuery = class _TableQuery {
3714
3928
  rowsAffected: response.rowsDeleted,
3715
3929
  executionMs: response.executionMs,
3716
3930
  hasMore: response.hasMore,
3931
+ token: response.token,
3717
3932
  ...response.rows ? { rows: columnarToRows(response.rows) } : {},
3718
3933
  ...response.rowsTruncated ? { rowsTruncated: true } : {}
3719
3934
  };
@@ -3733,7 +3948,8 @@ var TableQuery = class _TableQuery {
3733
3948
  return {
3734
3949
  rowsAffected: response.rowsDeleted,
3735
3950
  executionMs: response.executionMs,
3736
- hasMore: false
3951
+ hasMore: false,
3952
+ token: response.token
3737
3953
  };
3738
3954
  }
3739
3955
  /**
@@ -3746,7 +3962,14 @@ var TableQuery = class _TableQuery {
3746
3962
  throw new Error("batch() requires a non-empty operations array");
3747
3963
  }
3748
3964
  const wireOperations = operations.map((op, index) => {
3749
- const base = new _TableQuery(this.transport, this.tableName, this.database);
3965
+ const base = new _TableQuery(
3966
+ this.transport,
3967
+ this.tableName,
3968
+ this.database,
3969
+ void 0,
3970
+ this.getWebSocketTransport,
3971
+ this.store
3972
+ );
3750
3973
  const scoped = op.where(base);
3751
3974
  const where = scoped.buildWhereClause();
3752
3975
  if (!where) {
@@ -5083,12 +5306,12 @@ function raiseDeprecationWarnings(sink, warnings) {
5083
5306
  continue;
5084
5307
  }
5085
5308
  const sunset = warning.sunsetAt != null ? ` sunsetAt=${warning.sunsetAt}` : "";
5086
- const hash = warning.hash != null && warning.hash.length > 0 ? ` hash=${warning.hash}` : "";
5309
+ const name = warning.name != null && warning.name.length > 0 ? ` name=${warning.name}` : "";
5087
5310
  sink({
5088
5311
  code: warning.code,
5089
- hash: warning.hash,
5312
+ name: warning.name,
5090
5313
  sunsetAt: warning.sunsetAt,
5091
- message: `${warning.code}:${hash}${sunset}`.trim()
5314
+ message: `${warning.code}:${name}${sunset}`.trim()
5092
5315
  });
5093
5316
  }
5094
5317
  }
@@ -5101,21 +5324,31 @@ function emptyStats() {
5101
5324
  };
5102
5325
  }
5103
5326
  var NamedQueriesApi = class {
5104
- constructor(transport, database, onWarning, getStreamingTransport) {
5327
+ constructor(transport, database, onWarning, getStreamingTransport, store) {
5105
5328
  this.transport = transport;
5106
5329
  this.database = database;
5107
5330
  this.onWarning = onWarning;
5108
5331
  this.getStreamingTransport = getStreamingTransport;
5332
+ this.store = store;
5109
5333
  }
5110
- async execute(hash, args, options) {
5111
- if (typeof hash !== "string" || hash.trim().length === 0) {
5112
- throw new Error("Named query hash must be a non-empty string");
5334
+ async execute(name, args, options) {
5335
+ if (typeof name !== "string" || name.trim().length === 0) {
5336
+ throw new Error("Named query name must be a non-empty string");
5113
5337
  }
5114
5338
  const prefix = databasePath2(this.database);
5115
- const path4 = `${prefix}/named-queries/${encodeURIComponent(hash)}/query?format=columnar`;
5339
+ const path4 = `${prefix}/named-queries/${encodeURIComponent(name)}/query?format=columnar`;
5340
+ const body = {
5341
+ args: args ?? {}
5342
+ };
5343
+ if (options?.orderByIndex !== void 0) {
5344
+ body.orderByIndex = options.orderByIndex;
5345
+ }
5346
+ if (options?.atLeast !== void 0) {
5347
+ this.store?.observe(this.database, options.atLeast);
5348
+ }
5116
5349
  const response = await this.transport.post(
5117
5350
  path4,
5118
- { args: args ?? {} },
5351
+ body,
5119
5352
  { signal: options?.signal }
5120
5353
  );
5121
5354
  const rows = columnarToRows(response);
@@ -5123,7 +5356,9 @@ var NamedQueriesApi = class {
5123
5356
  return {
5124
5357
  rows,
5125
5358
  stats: response.stats,
5126
- warnings: response.warnings
5359
+ warnings: response.warnings,
5360
+ totalMatches: response.totalMatches,
5361
+ token: response.token
5127
5362
  };
5128
5363
  }
5129
5364
  async batch(items, options) {
@@ -5141,6 +5376,9 @@ var NamedQueriesApi = class {
5141
5376
  400
5142
5377
  );
5143
5378
  }
5379
+ if (options?.atLeast !== void 0) {
5380
+ this.store?.observe(this.database, options.atLeast);
5381
+ }
5144
5382
  const prefix = databasePath2(this.database);
5145
5383
  const path4 = `${prefix}/named-queries/batch?format=columnar`;
5146
5384
  const envelope = await this.transport.post(
@@ -5162,33 +5400,42 @@ var NamedQueriesApi = class {
5162
5400
  data: slot.data ?? [],
5163
5401
  rowCount: slot.rowCount ?? 0,
5164
5402
  stats: slot.stats ?? emptyStats(),
5165
- warnings: slot.warnings
5403
+ warnings: slot.warnings,
5404
+ totalMatches: slot.totalMatches,
5405
+ token: slot.token
5166
5406
  };
5167
5407
  const result = {
5168
5408
  rows: columnarToRows(columnar),
5169
5409
  stats: columnar.stats,
5170
- warnings: slot.warnings
5410
+ warnings: slot.warnings,
5411
+ totalMatches: slot.totalMatches,
5412
+ token: slot.token
5171
5413
  };
5172
5414
  raiseDeprecationWarnings(this.onWarning, slot.warnings);
5173
5415
  return { isError: false, result };
5174
5416
  });
5175
5417
  }
5176
- subscribe(hash, args, options = {}) {
5177
- if (typeof hash !== "string" || hash.trim().length === 0) {
5178
- throw new Error("Named query hash must be a non-empty string");
5418
+ subscribe(name, args, options = {}) {
5419
+ if (typeof name !== "string" || name.trim().length === 0) {
5420
+ throw new Error("Named query name must be a non-empty string");
5179
5421
  }
5180
5422
  const subscription = new TableSubscription(
5181
5423
  this.getStreamingTransport(),
5182
- { kind: "named", hash, args },
5424
+ { kind: "named", name, args, orderByIndex: options.orderByIndex },
5183
5425
  {
5184
5426
  onSnapshot: options.onSnapshot,
5185
5427
  onChange: options.onChange,
5186
5428
  onError: options.onError,
5187
- conflate: options.conflate
5429
+ conflate: options.conflate,
5430
+ atLeast: options.atLeast,
5431
+ waitMs: options.waitMs,
5432
+ onExceeded: options.onExceeded
5188
5433
  },
5189
5434
  (warnings) => {
5190
5435
  raiseDeprecationWarnings(this.onWarning, warnings);
5191
- }
5436
+ },
5437
+ this.store,
5438
+ this.database
5192
5439
  );
5193
5440
  subscription.start();
5194
5441
  return subscription;
@@ -5200,12 +5447,12 @@ var NamedMutationsApi = class {
5200
5447
  this.database = database;
5201
5448
  this.onWarning = onWarning;
5202
5449
  }
5203
- async execute(hash, args) {
5204
- if (typeof hash !== "string" || hash.trim().length === 0) {
5205
- throw new Error("Named mutation hash must be a non-empty string");
5450
+ async execute(name, args) {
5451
+ if (typeof name !== "string" || name.trim().length === 0) {
5452
+ throw new Error("Named mutation name must be a non-empty string");
5206
5453
  }
5207
5454
  const prefix = databasePath2(this.database);
5208
- const path4 = `${prefix}/named-mutations/${encodeURIComponent(hash)}/execute`;
5455
+ const path4 = `${prefix}/named-mutations/${encodeURIComponent(name)}/execute`;
5209
5456
  const wire = await this.transport.post(path4, {
5210
5457
  args: args ?? {}
5211
5458
  });
@@ -5266,6 +5513,7 @@ var WebSocketTransport = class {
5266
5513
  // Serialized-send queue: each send appends to this tail.
5267
5514
  this._sendTail = Promise.resolve();
5268
5515
  this._lastVersion = 0;
5516
+ this._lastToken = null;
5269
5517
  this._reconnectAttempt = 0;
5270
5518
  this._disposed = false;
5271
5519
  this._missedPings = 0;
@@ -5283,6 +5531,7 @@ var WebSocketTransport = class {
5283
5531
  this._pingIntervalMs = options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
5284
5532
  this._maxMissedHeartbeats = options.maxMissedHeartbeats ?? DEFAULT_MAX_MISSED_HEARTBEATS;
5285
5533
  this._enableCompression = options.enableCompression ?? true;
5534
+ this._store = options.consistencyTokenStore;
5286
5535
  this._wireMode = options.wireMode ?? "json";
5287
5536
  this._authHandler?.setOnAccessTokenRefreshed((token) => {
5288
5537
  if (this._handshakeComplete && this._ws?.readyState === WS_READY_STATE_OPEN) {
@@ -5291,10 +5540,14 @@ var WebSocketTransport = class {
5291
5540
  });
5292
5541
  }
5293
5542
  // ─── Public API ────────────────────────────────────────────────────────────
5294
- /** Latest version number from the most recent server `heartbeat` message. */
5543
+ /** Latest change-event sequence from the most recent server `heartbeat` message. */
5295
5544
  get lastVersion() {
5296
5545
  return this._lastVersion;
5297
5546
  }
5547
+ /** Consistency token from the most recent server `heartbeat` (ADR 0042 D-13). */
5548
+ get lastToken() {
5549
+ return this._lastToken;
5550
+ }
5298
5551
  /**
5299
5552
  * Registers a handler for incoming server messages with the given channel id.
5300
5553
  * Use `"__global__"` to receive broadcast messages that carry no `id`.
@@ -5482,6 +5735,10 @@ var WebSocketTransport = class {
5482
5735
  }
5483
5736
  case "heartbeat":
5484
5737
  this._lastVersion = msg.version;
5738
+ this._lastToken = msg.token ?? null;
5739
+ if (this._lastToken != null && this._lastToken.length > 0) {
5740
+ this._store?.observe(this._database, this._lastToken);
5741
+ }
5485
5742
  break;
5486
5743
  case "pong":
5487
5744
  this._missedPings = 0;
@@ -5647,16 +5904,21 @@ var LongPollTransport = class {
5647
5904
  this._connected = false;
5648
5905
  this._pollTask = null;
5649
5906
  this._lastVersion = 0;
5907
+ this._lastToken = null;
5650
5908
  this._serverUrl = serverUrl;
5651
5909
  this._database = database;
5652
5910
  this._authHandler = options.authHandler;
5653
5911
  this._onReconnected = options.onReconnected;
5654
5912
  this._waitMs = Math.max(1, options.waitMs ?? DEFAULT_WAIT_MS);
5655
5913
  this._fetch = options.fetchImpl ?? localNetworkFetch;
5914
+ this._store = options.consistencyTokenStore;
5656
5915
  }
5657
5916
  get lastVersion() {
5658
5917
  return this._lastVersion;
5659
5918
  }
5919
+ get lastToken() {
5920
+ return this._lastToken;
5921
+ }
5660
5922
  registerHandler(id, handler) {
5661
5923
  this._handlers.set(id, handler);
5662
5924
  }
@@ -5779,6 +6041,10 @@ var LongPollTransport = class {
5779
6041
  for (const msg of payload.messages) {
5780
6042
  if (msg.type === "heartbeat") {
5781
6043
  this._lastVersion = msg.version;
6044
+ this._lastToken = msg.token ?? null;
6045
+ if (this._lastToken != null && this._lastToken.length > 0) {
6046
+ this._store?.observe(this._database, this._lastToken);
6047
+ }
5782
6048
  }
5783
6049
  const id = "id" in msg ? msg.id : void 0;
5784
6050
  if (id !== void 0 && id !== null) {
@@ -5858,6 +6124,9 @@ var FallbackStreamingTransport = class {
5858
6124
  get lastVersion() {
5859
6125
  return this._active.lastVersion;
5860
6126
  }
6127
+ get lastToken() {
6128
+ return this._active.lastToken;
6129
+ }
5861
6130
  registerHandler(id, handler) {
5862
6131
  this._handlers.set(id, handler);
5863
6132
  this._active.registerHandler(id, handler);
@@ -5937,6 +6206,7 @@ var AoudaClient = class {
5937
6206
  this.baseUrl = normalizeBaseUrl(options.serverUrl);
5938
6207
  this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
5939
6208
  this.database = options.database.trim();
6209
+ this._store = options.consistencyTokenStore ?? new MemoryConsistencyTokenStore();
5940
6210
  if (!Number.isFinite(this.timeout) || this.timeout <= 0) {
5941
6211
  throw new Error("timeout must be a finite positive number");
5942
6212
  }
@@ -5971,7 +6241,9 @@ var AoudaClient = class {
5971
6241
  }
5972
6242
  const httpTransport = new HttpTransport({
5973
6243
  baseUrl: this.baseUrl,
5974
- timeout: this.timeout
6244
+ timeout: this.timeout,
6245
+ database: this.database,
6246
+ consistencyTokenStore: this._store
5975
6247
  });
5976
6248
  const activeAuth = options.serverAuth ?? options.appAuth;
5977
6249
  if (activeAuth) {
@@ -6031,7 +6303,8 @@ var AoudaClient = class {
6031
6303
  this.transport,
6032
6304
  this.database,
6033
6305
  onNamedArtifactWarning,
6034
- () => this._getOrCreateWebSocketTransport()
6306
+ () => this._getOrCreateWebSocketTransport(),
6307
+ this._store
6035
6308
  );
6036
6309
  this._namedMutations = new NamedMutationsApi(
6037
6310
  this.transport,
@@ -6130,7 +6403,8 @@ var AoudaClient = class {
6130
6403
  name,
6131
6404
  this.database,
6132
6405
  void 0,
6133
- () => this._getOrCreateWebSocketTransport()
6406
+ () => this._getOrCreateWebSocketTransport(),
6407
+ this._store
6134
6408
  );
6135
6409
  }
6136
6410
  /**
@@ -6182,13 +6456,26 @@ var AoudaClient = class {
6182
6456
  return this._materializedQueries;
6183
6457
  }
6184
6458
  /**
6185
- * Hash-only named-query execute and batch. Names are codegen aliases (D-5).
6459
+ * Named-query execute, read-only batch, and subscribe by unique schema name.
6186
6460
  */
6187
6461
  get namedQueries() {
6188
6462
  return this._namedQueries;
6189
6463
  }
6464
+ observeConsistencyToken(token) {
6465
+ this._store.observe(this.database, token);
6466
+ }
6467
+ getObservedConsistencyToken() {
6468
+ return this._store.get(this.database);
6469
+ }
6470
+ async getConsistencyToken() {
6471
+ const body = await this.transport.get(
6472
+ `/api/databases/${encodeURIComponent(this.database)}/token`
6473
+ );
6474
+ this._store.observe(this.database, body.token);
6475
+ return this._store.get(this.database) ?? body.token;
6476
+ }
6190
6477
  /**
6191
- * Hash-only named-mutation execute. No batch.
6478
+ * Named-mutation execute by unique schema name. No batch.
6192
6479
  */
6193
6480
  get namedMutations() {
6194
6481
  return this._namedMutations;
@@ -6315,13 +6602,15 @@ var AoudaClient = class {
6315
6602
  const primary = new WebSocketTransport(this.baseUrl, this.database, {
6316
6603
  authHandler: this._authHandler,
6317
6604
  enableCompression: this._streamingEnableCompression,
6318
- wireMode: this._streamingWireMode
6605
+ wireMode: this._streamingWireMode,
6606
+ consistencyTokenStore: this._store
6319
6607
  });
6320
6608
  if (this._streamingEnableLongPollFallback) {
6321
6609
  this._wsTransport = new FallbackStreamingTransport(primary, () => {
6322
6610
  return new LongPollTransport(this.baseUrl, this.database, {
6323
6611
  authHandler: this._authHandler,
6324
- waitMs: this._streamingLongPollWaitMs
6612
+ waitMs: this._streamingLongPollWaitMs,
6613
+ consistencyTokenStore: this._store
6325
6614
  });
6326
6615
  });
6327
6616
  } else {
@@ -6347,7 +6636,7 @@ Usage:
6347
6636
  npx @aouda/client schema <command> [options]
6348
6637
 
6349
6638
  Commands:
6350
- generate Fetch schema from Aouda server and output TypeScript types (tables + named query/mutation hashes)
6639
+ generate Fetch schema from Aouda server and output TypeScript types (tables + optional named query/mutation Args/Row)
6351
6640
  schema Schema management (diff, apply, export, validate, history, seed)
6352
6641
  diff Show migration plan (desired vs current)
6353
6642
  apply Apply schema (use --allow-destructive for drops; --dry-run to preview)