@hot-updater/aws 1.2.0-rc.12 → 1.2.0-rc.13

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
@@ -553,24 +553,36 @@ const EXPIRED_CREDENTIAL_ERRORS = /* @__PURE__ */ new Set([
553
553
  ]);
554
554
  /** Errors of the SDK finding no credentials, or an expired SSO session. */
555
555
  const NO_CREDENTIAL_ERRORS = /* @__PURE__ */ new Set(["CredentialsProviderError", "TokenProviderError"]);
556
- const SIGN_IN_HINT = ["In CI, set them with aws-actions/configure-aws-credentials.", "On your machine: aws sso login --profile <profile>, then set AWS_PROFILE."];
557
- /** The widest line a message has: the CLI prints no wider. */
558
- const LINE_WIDTH = 76;
559
- /** `text`, such as AWS's own message, in lines that fit, broken at spaces. */
560
- const wrap = (text) => {
561
- const lines = [];
562
- let line = "";
563
- for (const word of text.split(/\s+/u).filter(Boolean)) if (line && line.length + 1 + word.length > LINE_WIDTH) {
564
- lines.push(line);
565
- line = word;
566
- } else line = line ? `${line} ${word}` : word;
567
- if (line) lines.push(line);
568
- return lines;
569
- };
570
556
  /** An error of one line each, for a terminal. */
571
557
  const failure = (lines, cause) => new Error(lines.join("\n"), cause === void 0 ? void 0 : { cause });
572
558
  const errorName = (error) => error instanceof Error ? error.name : "";
573
559
  const errorMessage = (error) => (error instanceof Error ? error.message : String(error)).replace(/\.+$/u, "");
560
+ /** The access key variables set, which the SDK's chain tries first. */
561
+ const accessKeyVariables = () => {
562
+ if (!process.env["AWS_ACCESS_KEY_ID"] || !process.env["AWS_SECRET_ACCESS_KEY"]) return;
563
+ return process.env["AWS_SESSION_TOKEN"] ? "AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN" : "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY";
564
+ };
565
+ /**
566
+ * The one step that can fix the credentials the SDK actually used. `AWS_PROFILE`
567
+ * wins over the access key variables in the SDK's chain, so it's looked at
568
+ * first; signing in fixes a profile only when it's an SSO one, as its error
569
+ * says, and fixes nothing when another source is in the way.
570
+ */
571
+ const credentialHint = (error, configured) => {
572
+ if (configured !== void 0) return [`Check the credentials passed to ${NAME}().`];
573
+ const ci = Boolean(process.env["CI"]);
574
+ const profile = process.env["AWS_PROFILE"]?.trim();
575
+ if (profile) return !ci && /\bsso\b/iu.test(errorMessage(error)) ? ["Sign in again:", `aws sso login --profile ${profile}`] : [`Check the AWS profile ${profile}, which AWS_PROFILE selects.`];
576
+ const variables = accessKeyVariables();
577
+ if (variables) return [`Fix or unset ${variables}.`];
578
+ return ci ? ["Set them with aws-actions/configure-aws-credentials."] : ["Sign in, then set AWS_PROFILE:", "aws sso login --profile <profile>"];
579
+ };
580
+ /**
581
+ * The caller's ARN in a refusal, `User: arn:aws:sts::…`, so the message names
582
+ * who to grant. Only that token is read: the rest of AWS's message, such as
583
+ * the resource it refused, stays out.
584
+ */
585
+ const refusedCaller = (message) => /\bUser: (arn:aws[a-z-]*:(?:iam|sts)::\d*:[\w+=,.@/-]*[\w-])/u.exec(message)?.[1];
574
586
  const resolveEndpoint = (endpoint) => {
575
587
  let url;
576
588
  try {
@@ -650,40 +662,14 @@ const secretsManager = ({ credentials, endpoint, prefix = "hot-updater/", region
650
662
  const describeFailure = (verb, id, error, permissions) => {
651
663
  const name = errorName(error);
652
664
  const status = error?.$metadata?.httpStatusCode;
653
- const detail = wrap(`${name || "Error"}${status ? ` (${status})` : ""}: ${errorMessage(error)}.`);
654
- const secret = `Secret: ${id}`;
655
- if (NO_CREDENTIAL_ERRORS.has(name)) return failure([
656
- `${NAME} found no AWS credentials to ${verb} a secret.`,
657
- secret,
658
- ...wrap(`${errorMessage(error)}.`),
659
- ...SIGN_IN_HINT
660
- ], error);
661
- if (EXPIRED_CREDENTIAL_ERRORS.has(name)) return failure([
662
- `AWS Secrets Manager refused the sign-in to ${verb} a secret.`,
663
- secret,
664
- ...detail,
665
- "They are expired or invalid: sign in again.",
666
- ...SIGN_IN_HINT
667
- ], error);
668
- if (name === "AccessDeniedException") return failure([
669
- `AWS Secrets Manager refused to ${verb} a secret.`,
670
- secret,
671
- ...detail,
672
- `The AWS identity needs ${permissions} on it.`,
673
- `With a customer managed KMS key, it needs ${verb === "read" ? "kms:Decrypt" : "kms:GenerateDataKey"} too.`
674
- ], error);
675
- if (/marked for deletion|scheduled for deletion/iu.test(errorMessage(error))) return failure([
676
- `AWS Secrets Manager can't ${verb} a secret.`,
677
- secret,
678
- "It is scheduled for deletion. Restore it, or wait until it is gone:",
679
- "aws secretsmanager restore-secret \\",
680
- ` --secret-id ${id}`
681
- ], error);
682
- return failure([
683
- `AWS Secrets Manager failed to ${verb} a secret.`,
684
- secret,
685
- ...detail
686
- ], error);
665
+ if (NO_CREDENTIAL_ERRORS.has(name)) return failure([`${NAME} found no AWS credentials.`, ...credentialHint(error, credentials)], error);
666
+ if (EXPIRED_CREDENTIAL_ERRORS.has(name)) return failure(["AWS rejected the credentials: expired or invalid.", ...credentialHint(error, credentials)], error);
667
+ if (name === "AccessDeniedException") {
668
+ const key = verb === "read" ? "kms:Decrypt" : "kms:GenerateDataKey";
669
+ return failure([`AWS Secrets Manager refused to ${verb} ${id}.`, `${refusedCaller(errorMessage(error)) ?? "The identity"} needs ${/\bkms\b/iu.test(errorMessage(error)) ? key : permissions} on it.`], error);
670
+ }
671
+ if (/marked for deletion|scheduled for deletion/iu.test(errorMessage(error))) return failure([`${id} is scheduled for deletion. Restore it, or wait until it is gone:`, `aws secretsmanager restore-secret --secret-id ${id}`], error);
672
+ return failure([`AWS Secrets Manager failed to ${verb} ${id}.`, `${name || "Error"}${status ? ` (${status})` : ""}: ${errorMessage(error)}.`], error);
687
673
  };
688
674
  const read = async ({ sdk, client }, id) => {
689
675
  let output;
package/dist/index.d.cts CHANGED
@@ -26,9 +26,8 @@ interface DynamoDBConfig extends Omit<DynamoDBClientConfig, "credentials"> {
26
26
  readonly tableName: string;
27
27
  /**
28
28
  * DynamoDB bills each item a write touches, so Insights' aggregates are
29
- * batched: through log rows a compaction merges (the default), or
30
- * `{ mode: "memory" }` on a long-lived server. `false` commits them with
31
- * each event.
29
+ * batched: through log rows a compaction merges (the default). `false` commits
30
+ * them with each event.
32
31
  */
33
32
  readonly aggregateBatching?: EngineDatabase["aggregateBatching"] | false;
34
33
  }
package/dist/index.d.mts CHANGED
@@ -26,9 +26,8 @@ interface DynamoDBConfig extends Omit<DynamoDBClientConfig, "credentials"> {
26
26
  readonly tableName: string;
27
27
  /**
28
28
  * DynamoDB bills each item a write touches, so Insights' aggregates are
29
- * batched: through log rows a compaction merges (the default), or
30
- * `{ mode: "memory" }` on a long-lived server. `false` commits them with
31
- * each event.
29
+ * batched: through log rows a compaction merges (the default). `false` commits
30
+ * them with each event.
32
31
  */
33
32
  readonly aggregateBatching?: EngineDatabase["aggregateBatching"] | false;
34
33
  }
package/dist/index.mjs CHANGED
@@ -552,24 +552,36 @@ const EXPIRED_CREDENTIAL_ERRORS = /* @__PURE__ */ new Set([
552
552
  ]);
553
553
  /** Errors of the SDK finding no credentials, or an expired SSO session. */
554
554
  const NO_CREDENTIAL_ERRORS = /* @__PURE__ */ new Set(["CredentialsProviderError", "TokenProviderError"]);
555
- const SIGN_IN_HINT = ["In CI, set them with aws-actions/configure-aws-credentials.", "On your machine: aws sso login --profile <profile>, then set AWS_PROFILE."];
556
- /** The widest line a message has: the CLI prints no wider. */
557
- const LINE_WIDTH = 76;
558
- /** `text`, such as AWS's own message, in lines that fit, broken at spaces. */
559
- const wrap = (text) => {
560
- const lines = [];
561
- let line = "";
562
- for (const word of text.split(/\s+/u).filter(Boolean)) if (line && line.length + 1 + word.length > LINE_WIDTH) {
563
- lines.push(line);
564
- line = word;
565
- } else line = line ? `${line} ${word}` : word;
566
- if (line) lines.push(line);
567
- return lines;
568
- };
569
555
  /** An error of one line each, for a terminal. */
570
556
  const failure = (lines, cause) => new Error(lines.join("\n"), cause === void 0 ? void 0 : { cause });
571
557
  const errorName = (error) => error instanceof Error ? error.name : "";
572
558
  const errorMessage = (error) => (error instanceof Error ? error.message : String(error)).replace(/\.+$/u, "");
559
+ /** The access key variables set, which the SDK's chain tries first. */
560
+ const accessKeyVariables = () => {
561
+ if (!process.env["AWS_ACCESS_KEY_ID"] || !process.env["AWS_SECRET_ACCESS_KEY"]) return;
562
+ return process.env["AWS_SESSION_TOKEN"] ? "AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN" : "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY";
563
+ };
564
+ /**
565
+ * The one step that can fix the credentials the SDK actually used. `AWS_PROFILE`
566
+ * wins over the access key variables in the SDK's chain, so it's looked at
567
+ * first; signing in fixes a profile only when it's an SSO one, as its error
568
+ * says, and fixes nothing when another source is in the way.
569
+ */
570
+ const credentialHint = (error, configured) => {
571
+ if (configured !== void 0) return [`Check the credentials passed to ${NAME}().`];
572
+ const ci = Boolean(process.env["CI"]);
573
+ const profile = process.env["AWS_PROFILE"]?.trim();
574
+ if (profile) return !ci && /\bsso\b/iu.test(errorMessage(error)) ? ["Sign in again:", `aws sso login --profile ${profile}`] : [`Check the AWS profile ${profile}, which AWS_PROFILE selects.`];
575
+ const variables = accessKeyVariables();
576
+ if (variables) return [`Fix or unset ${variables}.`];
577
+ return ci ? ["Set them with aws-actions/configure-aws-credentials."] : ["Sign in, then set AWS_PROFILE:", "aws sso login --profile <profile>"];
578
+ };
579
+ /**
580
+ * The caller's ARN in a refusal, `User: arn:aws:sts::…`, so the message names
581
+ * who to grant. Only that token is read: the rest of AWS's message, such as
582
+ * the resource it refused, stays out.
583
+ */
584
+ const refusedCaller = (message) => /\bUser: (arn:aws[a-z-]*:(?:iam|sts)::\d*:[\w+=,.@/-]*[\w-])/u.exec(message)?.[1];
573
585
  const resolveEndpoint = (endpoint) => {
574
586
  let url;
575
587
  try {
@@ -649,40 +661,14 @@ const secretsManager = ({ credentials, endpoint, prefix = "hot-updater/", region
649
661
  const describeFailure = (verb, id, error, permissions) => {
650
662
  const name = errorName(error);
651
663
  const status = error?.$metadata?.httpStatusCode;
652
- const detail = wrap(`${name || "Error"}${status ? ` (${status})` : ""}: ${errorMessage(error)}.`);
653
- const secret = `Secret: ${id}`;
654
- if (NO_CREDENTIAL_ERRORS.has(name)) return failure([
655
- `${NAME} found no AWS credentials to ${verb} a secret.`,
656
- secret,
657
- ...wrap(`${errorMessage(error)}.`),
658
- ...SIGN_IN_HINT
659
- ], error);
660
- if (EXPIRED_CREDENTIAL_ERRORS.has(name)) return failure([
661
- `AWS Secrets Manager refused the sign-in to ${verb} a secret.`,
662
- secret,
663
- ...detail,
664
- "They are expired or invalid: sign in again.",
665
- ...SIGN_IN_HINT
666
- ], error);
667
- if (name === "AccessDeniedException") return failure([
668
- `AWS Secrets Manager refused to ${verb} a secret.`,
669
- secret,
670
- ...detail,
671
- `The AWS identity needs ${permissions} on it.`,
672
- `With a customer managed KMS key, it needs ${verb === "read" ? "kms:Decrypt" : "kms:GenerateDataKey"} too.`
673
- ], error);
674
- if (/marked for deletion|scheduled for deletion/iu.test(errorMessage(error))) return failure([
675
- `AWS Secrets Manager can't ${verb} a secret.`,
676
- secret,
677
- "It is scheduled for deletion. Restore it, or wait until it is gone:",
678
- "aws secretsmanager restore-secret \\",
679
- ` --secret-id ${id}`
680
- ], error);
681
- return failure([
682
- `AWS Secrets Manager failed to ${verb} a secret.`,
683
- secret,
684
- ...detail
685
- ], error);
664
+ if (NO_CREDENTIAL_ERRORS.has(name)) return failure([`${NAME} found no AWS credentials.`, ...credentialHint(error, credentials)], error);
665
+ if (EXPIRED_CREDENTIAL_ERRORS.has(name)) return failure(["AWS rejected the credentials: expired or invalid.", ...credentialHint(error, credentials)], error);
666
+ if (name === "AccessDeniedException") {
667
+ const key = verb === "read" ? "kms:Decrypt" : "kms:GenerateDataKey";
668
+ return failure([`AWS Secrets Manager refused to ${verb} ${id}.`, `${refusedCaller(errorMessage(error)) ?? "The identity"} needs ${/\bkms\b/iu.test(errorMessage(error)) ? key : permissions} on it.`], error);
669
+ }
670
+ if (/marked for deletion|scheduled for deletion/iu.test(errorMessage(error))) return failure([`${id} is scheduled for deletion. Restore it, or wait until it is gone:`, `aws secretsmanager restore-secret --secret-id ${id}`], error);
671
+ return failure([`AWS Secrets Manager failed to ${verb} ${id}.`, `${name || "Error"}${status ? ` (${status})` : ""}: ${errorMessage(error)}.`], error);
686
672
  };
687
673
  const read = async ({ sdk, client }, id) => {
688
674
  let output;
@@ -2131,7 +2131,10 @@ const CHANNEL_ID = {
2131
2131
  * every database's migrator creates missing tables and then records the
2132
2132
  * version, and none of them changes an existing table, so changing one
2133
2133
  * needs a migration step of its own first. The schema fence answers 503 on
2134
- * a database whose row differs, until it runs.
2134
+ * a database whose row differs, until it runs. Before a version is released,
2135
+ * its tables change in place in its own migration, and a database one of its
2136
+ * prereleases migrated takes the same change by hand: the fence compares this
2137
+ * row, not columns, so nothing reports it.
2135
2138
  */
2136
2139
  const HOT_UPDATER_SCHEMA_VERSION = "1.2.0";
2137
2140
  const bundles = defineTable({
@@ -2395,6 +2398,10 @@ const channels = defineTable({
2395
2398
  * or `versionCode`. `submitted_to`, `submitted_at_ms` and
2396
2399
  * `submitted_version` record the store track a build went to, when, and
2397
2400
  * the store's label of the build; they stay null for a build no store took.
2401
+ * `store_build` is true on the build a person or a job says the store sells,
2402
+ * and null on every other: deploys patch from it over the scope's newest build.
2403
+ * It is never false, so `byStoreBuild` holds one entry per scope at most, on
2404
+ * every store, and an unmarked row, older than the column or not, holds none.
2398
2405
  */
2399
2406
  const nativeBuilds = defineTable({
2400
2407
  id: {
@@ -2471,6 +2478,10 @@ const nativeBuilds = defineTable({
2471
2478
  type: "string",
2472
2479
  maxLength: 255,
2473
2480
  required: false
2481
+ },
2482
+ store_build: {
2483
+ type: "boolean",
2484
+ required: false
2474
2485
  }
2475
2486
  }, {
2476
2487
  key: ["id"],
@@ -2483,6 +2494,11 @@ const nativeBuilds = defineTable({
2483
2494
  eq: ["scope_key", "build_key"],
2484
2495
  sort: ["id"]
2485
2496
  },
2497
+ byStoreBuild: {
2498
+ eq: ["scope_key", "store_build"],
2499
+ sort: [],
2500
+ unique: true
2501
+ },
2486
2502
  byChannel: {
2487
2503
  eq: ["channel_id"],
2488
2504
  sort: ["id"],
@@ -2703,15 +2719,8 @@ const LEASE_TABLE = "aggregate_lease";
2703
2719
  const LEASE = { id: "compaction" };
2704
2720
  /** How long a compaction holds the lease before another process may take it. */
2705
2721
  const LEASE_MS = 3e4;
2706
- /**
2707
- * Default windows: log mode compacts every 60 seconds, since reads compact
2708
- * first; memory mode flushes every 15, since its window is what a crash
2709
- * loses.
2710
- */
2711
- const WINDOW_MS$1 = {
2712
- log: 6e4,
2713
- memory: 15e3
2714
- };
2722
+ /** A commit compacts once this has passed since the last compaction. */
2723
+ const WINDOW_MS$1 = 6e4;
2715
2724
  /** Log rows a compaction reads from each log table for one group. */
2716
2725
  const LOG_PAGE = 200;
2717
2726
  /**
@@ -2725,8 +2734,6 @@ const GROUPS = {
2725
2734
  };
2726
2735
  /** Tries per write after a conflict or a transient error. */
2727
2736
  const ATTEMPTS = 5;
2728
- /** Aggregate rows a memory buffer holds before a commit waits for a flush. */
2729
- const MAX_BUFFERED = 1e4;
2730
2737
  const log = defineTable({
2731
2738
  id: {
2732
2739
  type: "string",
@@ -2776,7 +2783,7 @@ const valuesOf = ({ deltas, sketches }) => ({
2776
2783
  /**
2777
2784
  * Merges `values` into the change for `key`'s identity as `tx.aggregate`
2778
2785
  * does: deltas add up and sketches merge. One constant `shardBy` keys each
2779
- * identity to one entry; the write picks the shard.
2786
+ * identity to one entry; `compile` writes it to shard 0.
2780
2787
  */
2781
2788
  const merge = (into, model, key, values) => recordAggregate(into, model, Object.fromEntries(shapeOf(model).key.map((field, at) => [field, key[at]])), values, "");
2782
2789
  const mergeAll = (into, changes) => {
@@ -2810,7 +2817,7 @@ const increment = ({ model, deltas }, key) => Object.values(deltas).some((delta)
2810
2817
  /**
2811
2818
  * The guarded write that merges `change` into `current`: insert, patch, or
2812
2819
  * delete at zero. Unlike a transaction's, a gauge may go below zero here: a
2813
- * shard row holds one writer's net changes, and reads sum every shard.
2820
+ * group can hold a loss without the gain before it, and reads sum every shard.
2814
2821
  */
2815
2822
  const rewrite = (change, key, current) => {
2816
2823
  const { model } = change;
@@ -2848,12 +2855,12 @@ const rewrite = (change, key, current) => {
2848
2855
  };
2849
2856
  };
2850
2857
  /**
2851
- * The write for each change on its shard: a blind increment that creates
2852
- * the row for counters, and a guarded rewrite for gauges and sketches, whose
2853
- * rows are read in one batch per table.
2858
+ * The write for each change on shard 0, since one compaction runs at a time:
2859
+ * a blind increment that creates the row for counters, and a guarded rewrite
2860
+ * for gauges and sketches, whose rows are read in one batch per table.
2854
2861
  */
2855
- const compile = async (adapter, changes, shard) => {
2856
- const keys = changes.map(({ model, key }) => [...key.slice(0, -1), shard(model)]);
2862
+ const compile = async (adapter, changes) => {
2863
+ const keys = changes.map(({ key }) => [...key.slice(0, -1), 0]);
2857
2864
  const reads = /* @__PURE__ */ new Map();
2858
2865
  changes.forEach((change, at) => {
2859
2866
  if (rewrites(change)) reads.set(change.model, [...reads.get(change.model) ?? [], at]);
@@ -2907,14 +2914,13 @@ const encode = async (changes) => {
2907
2914
  return zipped !== void 0 && zipped.length < json.length ? `z${zipped}` : json;
2908
2915
  };
2909
2916
  /**
2910
- * Batches changes to aggregates declared `batched`. A transaction records
2911
- * them without writing their rows; once it commits, "memory" mode keeps
2912
- * them in this process until its timer flushes them, and "log" mode has
2913
- * written them as one log row in the same atomic write, which a compaction
2914
- * later merges into the aggregates and deletes, atomically again, so each
2915
- * applies exactly once. One lease row keeps compactions one at a time.
2917
+ * Batches changes to aggregates declared `batched`. A transaction writes
2918
+ * them as one log row in its own atomic write, in place of their rows; a
2919
+ * compaction later merges log rows into the aggregates and deletes them,
2920
+ * atomically again, so each applies exactly once. One lease row keeps
2921
+ * compactions one at a time.
2916
2922
  */
2917
- const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "log", windowMs = WINDOW_MS$1[mode] }, now }) => {
2923
+ const createAggregateBatches = ({ engine, adapter, schema, batching: { windowMs = WINDOW_MS$1 }, now }) => {
2918
2924
  if (!schema.models.has(LEASE_TABLE)) throw new DatabaseTransactionError("An engine that batches aggregates needs aggregateBatchingModule in its schema.");
2919
2925
  const holder = crypto.randomUUID().replaceAll("-", "");
2920
2926
  const table = (name) => schema.models.get(name).table;
@@ -2942,68 +2948,6 @@ const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "l
2942
2948
  }
2943
2949
  return changes;
2944
2950
  };
2945
- /**
2946
- * Writes changes in atomic chunks within `fits`, and returns the ones it
2947
- * could not apply. A write whose outcome is unknown is dropped, never
2948
- * resent, so no change applies twice.
2949
- */
2950
- const apply = async (changes, shard) => {
2951
- let rest = changes;
2952
- let ops = [];
2953
- let read = true;
2954
- for (let failures = 0; rest.length > 0 && failures < ATTEMPTS;) try {
2955
- if (read) ops = await compile(adapter, rest, shard);
2956
- let size = rest.length;
2957
- const chunk = () => ops.slice(0, size).filter(isOp);
2958
- while (size > 1 && !adapter.fits(chunk())) size = Math.ceil(size / 2);
2959
- let result = { ok: true };
2960
- if (chunk().length > 0) result = await adapter.write(chunk()).catch((error) => {
2961
- warn("flush")(error);
2962
- return { ok: true };
2963
- });
2964
- read = !result.ok && !("retry" in result);
2965
- if (result.ok) {
2966
- rest = rest.slice(size);
2967
- ops = ops.slice(size);
2968
- failures = 0;
2969
- } else {
2970
- failures += 1;
2971
- if (!read) await sleep$2(failures);
2972
- }
2973
- } catch (error) {
2974
- warn("flush")(error);
2975
- return rest;
2976
- }
2977
- return rest;
2978
- };
2979
- let buffer = /* @__PURE__ */ new Map();
2980
- let timer;
2981
- let flushing;
2982
- const shards = /* @__PURE__ */ new Map();
2983
- /** Each process writes its own shard, so flushes of two processes rarely meet. */
2984
- const processShard = (model) => {
2985
- if (!shards.has(model)) shards.set(model, Math.floor(Math.random() * shapeOf(model).shards));
2986
- return shards.get(model);
2987
- };
2988
- const schedule = () => {
2989
- if (timer !== void 0 || buffer.size === 0) return;
2990
- timer = setTimeout(() => {
2991
- timer = void 0;
2992
- flushBuffer();
2993
- }, windowMs);
2994
- timer.unref?.();
2995
- };
2996
- /** Never rejects: what it cannot apply goes back into the buffer. */
2997
- const flushBuffer = () => flushing ??= (async () => {
2998
- clearTimeout(timer);
2999
- timer = void 0;
3000
- const pending = [...buffer.values()];
3001
- buffer = /* @__PURE__ */ new Map();
3002
- mergeAll(buffer, await apply(pending, processShard));
3003
- })().catch(warn("flush")).finally(() => {
3004
- flushing = void 0;
3005
- schedule();
3006
- });
3007
2951
  let nextDue = 0;
3008
2952
  let compacting;
3009
2953
  /** Log rows this process read, by `[log table's index, id]`, for the deletes after a group. */
@@ -3138,7 +3082,7 @@ const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "l
3138
3082
  more
3139
3083
  };
3140
3084
  };
3141
- const rowsFor = async (changes) => (await compile(adapter, changes, () => 0)).filter(isOp);
3085
+ const rowsFor = async (changes) => (await compile(adapter, changes)).filter(isOp);
3142
3086
  /**
3143
3087
  * Applies the oldest pending log rows whose merged changes fit in one
3144
3088
  * atomic write with the lease, which lists them as applied; the rows are
@@ -3232,20 +3176,6 @@ const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "l
3232
3176
  }))).then((pages) => pages.some((page) => page.rows.length > 0)).finally(() => {
3233
3177
  checking = void 0;
3234
3178
  });
3235
- /** Takes a committed transaction's changes; never rejects, since the transaction already committed. */
3236
- const committed = async (changes) => {
3237
- if (mode === "log") {
3238
- if (now() >= nextDue) await compact(false, GROUPS.commit);
3239
- return;
3240
- }
3241
- mergeAll(buffer, changes.values());
3242
- schedule();
3243
- if (buffer.size >= MAX_BUFFERED) await flushBuffer();
3244
- };
3245
- const flush = async () => {
3246
- await flushBuffer();
3247
- if (mode === "log" && await hasPending()) await compact(true, GROUPS.flush);
3248
- };
3249
3179
  return {
3250
3180
  async transaction(fn) {
3251
3181
  let changes = /* @__PURE__ */ new Map();
@@ -3271,7 +3201,7 @@ const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "l
3271
3201
  aggregate
3272
3202
  });
3273
3203
  if (failure) throw failure.error;
3274
- if (mode === "log" && attempt.size > 0) {
3204
+ if (attempt.size > 0) {
3275
3205
  const id = `${now().toString(36).padStart(9, "0")}${holder.slice(0, 8)}${crypto.randomUUID().slice(0, 8)}`;
3276
3206
  const shard = Math.floor(Math.random() * LOG_TABLES.length);
3277
3207
  const merged = mergeAll(/* @__PURE__ */ new Map(), attempt.values());
@@ -3285,24 +3215,18 @@ const createAggregateBatches = ({ engine, adapter, schema, batching: { mode = "l
3285
3215
  open = false;
3286
3216
  }
3287
3217
  });
3288
- if (changes.size > 0) await committed(changes);
3218
+ if (changes.size > 0 && now() >= nextDue) await compact(false, GROUPS.commit);
3289
3219
  return result;
3290
3220
  },
3291
3221
  async beforeRead(name) {
3292
3222
  if (!isBatched(schema.models.get(name))) return;
3293
- if (buffer.size > 0 || flushing !== void 0) await flushBuffer();
3294
- if (mode === "log") {
3295
- if (await hasPending().catch((error) => {
3296
- warn("read")(error);
3297
- return false;
3298
- })) await compact(true, GROUPS.read);
3299
- }
3223
+ if (await hasPending().catch((error) => {
3224
+ warn("read")(error);
3225
+ return false;
3226
+ })) await compact(true, GROUPS.read);
3300
3227
  },
3301
- flush,
3302
- async dispose() {
3303
- clearTimeout(timer);
3304
- timer = void 0;
3305
- await flush();
3228
+ async flush() {
3229
+ if (await hasPending()) await compact(true, GROUPS.flush);
3306
3230
  }
3307
3231
  };
3308
3232
  };
@@ -4331,13 +4255,10 @@ const createDatabaseEngine = ({ batching, now = Date.now, meter, ...options }) =
4331
4255
  };
4332
4256
  return {
4333
4257
  ...engine,
4334
- /** Applies batched aggregate changes still pending: this process's buffer and the log. */
4258
+ /** Applies the batched aggregate changes still in the log. */
4335
4259
  flush: async () => {
4336
4260
  await batches?.flush();
4337
4261
  },
4338
- dispose: async () => {
4339
- await batches?.dispose();
4340
- },
4341
4262
  database(module) {
4342
4263
  const name = (model) => module.namespace ? `${module.namespace}_${model}` : model;
4343
4264
  return {
@@ -4418,8 +4339,7 @@ const createEngine = (database, { plugins = [], now = Date.now } = {}) => {
4418
4339
  if (module === void 0) throw new HotUpdaterConfigError(`Plugin "${plugin.id}" is not one of this engine's plugins.`);
4419
4340
  return engine.database(module);
4420
4341
  },
4421
- flush: () => engine.flush(),
4422
- dispose: () => engine.dispose()
4342
+ flush: () => engine.flush()
4423
4343
  };
4424
4344
  };
4425
4345
  //#endregion
@@ -4712,7 +4632,7 @@ const createKvAdapter = ({ store, tablePrefix = "" }) => {
4712
4632
  };
4713
4633
  //#endregion
4714
4634
  //#region ../../packages/server/dist/version.mjs
4715
- const HOT_UPDATER_SERVER_VERSION = "1.2.0-rc.12";
4635
+ const HOT_UPDATER_SERVER_VERSION = "1.2.0-rc.13";
4716
4636
  //#endregion
4717
4637
  //#region ../../packages/server/dist/handlerVersionRoutes.mjs
4718
4638
  /**
@@ -5917,8 +5837,22 @@ const pick = (row) => ({
5917
5837
  build_number: row.build_number ?? null,
5918
5838
  submitted_to: row.submitted_to ?? null,
5919
5839
  submitted_at_ms: row.submitted_at_ms ?? null,
5920
- submitted_version: row.submitted_version ?? null
5840
+ submitted_version: row.submitted_version ?? null,
5841
+ store_build: row.store_build === true ? true : null
5921
5842
  });
5843
+ /**
5844
+ * The channels only sandbox builds register on, which no store sells: the
5845
+ * registry's convention, as `@hot-updater/protocol/native-artifact` checks it
5846
+ * for a build's artifact.
5847
+ */
5848
+ const SANDBOX_CHANNEL_PREFIX = "sandbox-";
5849
+ /**
5850
+ * How many older builds of a scope one read takes, looking for one older than
5851
+ * a bundle when a build made after the bundle is the newest.
5852
+ */
5853
+ const OLDER_BUILDS_PAGE = 20;
5854
+ /** How many times a mark runs again after losing its scope's mark to another. */
5855
+ const MARK_ATTEMPTS = 8;
5922
5856
  /** The store track each platform's builds are submitted to. */
5923
5857
  const SUBMISSION_DESTINATIONS = {
5924
5858
  android: "play-internal",
@@ -5995,7 +5929,8 @@ const createNativeBuildApi = (db, { now = Date.now } = {}) => ({
5995
5929
  build_number: registration.buildNumber ?? null,
5996
5930
  submitted_to: null,
5997
5931
  submitted_at_ms: null,
5998
- submitted_version: null
5932
+ submitted_version: null,
5933
+ store_build: null
5999
5934
  };
6000
5935
  for (let attempt = 1;; attempt += 1) {
6001
5936
  await insertChannel(db, {
@@ -6033,21 +5968,45 @@ const createNativeBuildApi = (db, { now = Date.now } = {}) => ({
6033
5968
  const scopeKey = nativeBuildScopeKey(scope);
6034
5969
  const buildKey = options?.buildKey;
6035
5970
  if (buildKey !== void 0 && !BUILD_KEY.test(buildKey)) throw new NativeBuildInputError("Invalid native build key: it must be nbk1: and a SHA-256.");
6036
- const { rows } = buildKey === void 0 ? await db.findMany("native_builds", {
6037
- index: "byScope",
6038
- where: { scope_key: scopeKey },
6039
- order: "desc",
6040
- limit: 1
6041
- }) : await db.findMany("native_builds", {
6042
- index: "byBuildKey",
6043
- where: {
5971
+ if (options?.storeBuild) {
5972
+ const marked = await db.findOne("native_builds", {
6044
5973
  scope_key: scopeKey,
6045
- build_key: buildKey
6046
- },
6047
- order: "desc",
6048
- limit: 1
6049
- });
6050
- return rows[0] === void 0 ? null : pick(rows[0]);
5974
+ store_build: true
5975
+ });
5976
+ return marked === null ? null : pick(marked);
5977
+ }
5978
+ const read = (limit, before) => {
5979
+ const page = {
5980
+ order: "desc",
5981
+ limit,
5982
+ ...before === void 0 ? {} : { range: { lt: before } }
5983
+ };
5984
+ return buildKey === void 0 ? db.findMany("native_builds", {
5985
+ index: "byScope",
5986
+ where: { scope_key: scopeKey },
5987
+ ...page
5988
+ }) : db.findMany("native_builds", {
5989
+ index: "byBuildKey",
5990
+ where: {
5991
+ scope_key: scopeKey,
5992
+ build_key: buildKey
5993
+ },
5994
+ ...page
5995
+ });
5996
+ };
5997
+ const { rows } = await read(1);
5998
+ const newest = rows[0];
5999
+ if (newest === void 0) return null;
6000
+ const bundleId = options?.builtInOlderThan;
6001
+ if (bundleId === void 0 || newest.min_bundle_id < bundleId) return pick(newest);
6002
+ for (let before = newest.id;;) {
6003
+ const { rows: older } = await read(OLDER_BUILDS_PAGE, before);
6004
+ const usable = older.find(({ min_bundle_id }) => min_bundle_id < bundleId);
6005
+ if (usable !== void 0) return pick(usable);
6006
+ const last = older.at(-1);
6007
+ if (older.length < OLDER_BUILDS_PAGE || last === void 0) return null;
6008
+ before = last.id;
6009
+ }
6051
6010
  },
6052
6011
  async recordNativeBuildSubmission(id, { destination, version }) {
6053
6012
  if (version.trim().length === 0 || version.length > SUBMITTED_VERSION_MAX_LENGTH) throw new NativeBuildInputError(`Invalid native build submission: version must be the store's label of the build, up to ${SUBMITTED_VERSION_MAX_LENGTH} characters.`);
@@ -6068,6 +6027,49 @@ const createNativeBuildApi = (db, { now = Date.now } = {}) => ({
6068
6027
  };
6069
6028
  });
6070
6029
  },
6030
+ async markNativeBuild(id, { storeBuild }) {
6031
+ for (let attempt = 1;; attempt += 1) try {
6032
+ return await db.transaction(async (tx) => {
6033
+ const row = await tx.findOne("native_builds", { id });
6034
+ if (row === null) return null;
6035
+ const build = pick(row);
6036
+ if (!storeBuild) {
6037
+ if (build.store_build !== null) tx.update("native_builds", row, { store_build: null });
6038
+ return {
6039
+ nativeBuild: {
6040
+ ...build,
6041
+ store_build: null
6042
+ },
6043
+ previous: null
6044
+ };
6045
+ }
6046
+ if (decodeChannelKey(build.channel_id.slice(8)).startsWith(SANDBOX_CHANNEL_PREFIX)) throw new NativeBuildInputError("A build on a sandbox channel can't be the store build.");
6047
+ if (build.store_build !== null) return {
6048
+ nativeBuild: build,
6049
+ previous: null
6050
+ };
6051
+ const holder = await tx.findOne("native_builds", {
6052
+ scope_key: build.scope_key,
6053
+ store_build: true
6054
+ });
6055
+ if (holder !== null) tx.update("native_builds", holder, { store_build: null });
6056
+ tx.update("native_builds", row, { store_build: true });
6057
+ return {
6058
+ nativeBuild: {
6059
+ ...build,
6060
+ store_build: true
6061
+ },
6062
+ previous: holder === null ? null : {
6063
+ ...pick(holder),
6064
+ store_build: null
6065
+ }
6066
+ };
6067
+ });
6068
+ } catch (error) {
6069
+ if (attempt < MARK_ATTEMPTS && error instanceof DatabaseConstraintError && error.reason === "unique") continue;
6070
+ throw error;
6071
+ }
6072
+ },
6071
6073
  async deleteNativeBuild(id) {
6072
6074
  return db.transaction(async (tx) => {
6073
6075
  const row = await tx.findOne("native_builds", { id });
@@ -7150,7 +7152,13 @@ const createAdminRouteHandlers = () => ({
7150
7152
  const url = new URL(request.url);
7151
7153
  const scope = nativeBuildScopeOf(url);
7152
7154
  const buildKey = url.searchParams.get("buildKey");
7153
- return json$2({ data: await refusingInvalid(() => buildKey === null ? core.findNativeBuild(scope) : core.findNativeBuild(scope, { buildKey })) });
7155
+ const builtInOlderThan = url.searchParams.get("builtInOlderThan");
7156
+ const options = {
7157
+ ...buildKey === null ? {} : { buildKey },
7158
+ ...url.searchParams.get("storeBuild") === "true" ? { storeBuild: true } : {},
7159
+ ...builtInOlderThan === null ? {} : { builtInOlderThan }
7160
+ };
7161
+ return json$2({ data: await refusingInvalid(() => Object.keys(options).length === 0 ? core.findNativeBuild(scope) : core.findNativeBuild(scope, options)) });
7154
7162
  }),
7155
7163
  getNativeBuild: onCore(async (core, _request, params) => json$2({ data: await core.getNativeBuild(requireRouteParam(params, "id")) })),
7156
7164
  listNativeBuilds: onCore(async (core, request) => {
@@ -7171,6 +7179,12 @@ const createAdminRouteHandlers = () => ({
7171
7179
  };
7172
7180
  return json$2({ data: await refusingInvalid(() => core.recordNativeBuildSubmission(requireRouteParam(params, "id"), submission)) });
7173
7181
  }),
7182
+ markNativeBuild: onCore(async (core, request, params) => {
7183
+ const input = await body(request);
7184
+ if (!isRecord$5(input) || typeof input.storeBuild !== "boolean") throw new HandlerBadRequestError("A native build's store build mark needs storeBuild, true or false.");
7185
+ const mark = { storeBuild: input.storeBuild };
7186
+ return json$2({ data: await refusingInvalid(() => core.markNativeBuild(requireRouteParam(params, "id"), mark)) });
7187
+ }),
7174
7188
  deleteNativeBuild: onCore(async (core, _request, params) => json$2({ data: await core.deleteNativeBuild(requireRouteParam(params, "id")) })),
7175
7189
  deleteBundles: onCore(async (core, request) => {
7176
7190
  const { ids } = await body(request);
@@ -7316,6 +7330,11 @@ const ADMIN_ROUTES = [
7316
7330
  path: "/native-builds/:id/submission",
7317
7331
  handler: "recordNativeBuildSubmission"
7318
7332
  },
7333
+ {
7334
+ method: "POST",
7335
+ path: "/native-builds/:id/store-build",
7336
+ handler: "markNativeBuild"
7337
+ },
7319
7338
  {
7320
7339
  method: "DELETE",
7321
7340
  path: "/native-builds/:id",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/aws",
3
3
  "type": "module",
4
- "version": "1.2.0-rc.12",
4
+ "version": "1.2.0-rc.13",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.mjs",
@@ -45,9 +45,9 @@
45
45
  "es-toolkit": "^1.32.0",
46
46
  "execa": "9.5.2",
47
47
  "mime": "^4.0.4",
48
- "@hot-updater/mock": "1.2.0-rc.12",
49
- "@hot-updater/protocol": "1.2.0-rc.12",
50
- "@hot-updater/test-utils": "1.2.0-rc.12"
48
+ "@hot-updater/protocol": "1.2.0-rc.13",
49
+ "@hot-updater/mock": "1.2.0-rc.13",
50
+ "@hot-updater/test-utils": "1.2.0-rc.13"
51
51
  },
52
52
  "dependencies": {
53
53
  "@aws-sdk/client-cloudfront": "3.1066.0",
@@ -64,10 +64,10 @@
64
64
  "@aws-sdk/lib-dynamodb": "3.1066.0",
65
65
  "@aws-sdk/s3-request-presigner": "3.1066.0",
66
66
  "hono": "4.12.34",
67
- "@hot-updater/plugin-core": "1.2.0-rc.12",
68
- "@hot-updater/storage-s3": "1.2.0-rc.12",
69
- "@hot-updater/server": "1.2.0-rc.12",
70
- "@hot-updater/cli-tools": "1.2.0-rc.12"
67
+ "@hot-updater/plugin-core": "1.2.0-rc.13",
68
+ "@hot-updater/server": "1.2.0-rc.13",
69
+ "@hot-updater/storage-s3": "1.2.0-rc.13",
70
+ "@hot-updater/cli-tools": "1.2.0-rc.13"
71
71
  },
72
72
  "scripts": {
73
73
  "build": "tsdown",