blogwright-core 0.3.3 → 0.4.0-beta.1

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/state.d.ts CHANGED
@@ -9,14 +9,35 @@ export interface OpsState {
9
9
  }
10
10
  export declare function emptyState(env: string): OpsState;
11
11
  /**
12
- * S3-backed topology state. Lives at `s3://<bucket>/state/<env>.json` so it is shared
13
- * across machines and is the single source of truth for what has been provisioned.
12
+ * S3-backed topology state: the single source of truth for what has been provisioned.
13
+ *
14
+ * An unscoped store (three constructor arguments) keys `s3://<bucket>/state/<env>.json`.
15
+ * This is on-disk identity for every environment that already exists - it must never move,
16
+ * or every existing site's state becomes unreadable.
17
+ *
18
+ * A store scoped to a plugin (the fourth argument) keys
19
+ * `s3://<bucket>/state/<env>.<plugin>.json` instead, so a plugin's resources are recorded
20
+ * separately from the site's: `blogwright <plugin> destroy` never touches, and never
21
+ * discards, the site's own record of what exists.
22
+ *
23
+ * Scoping changes the key, not the bucket - a scoped and an unscoped store for the same
24
+ * environment are constructed over the same `names.bucket` (see
25
+ * `packages/cli/src/context.ts:134`) and both objects live side by side under that
26
+ * bucket's `state/` prefix. That prefix is not itself a safety boundary: the site's own
27
+ * bucket node empties it wholesale before deleting the bucket
28
+ * (`deletePrefix(ctx.names.bucket, '')`, `packages/cli/src/nodes.ts:66`), which would take
29
+ * every `state/<env>.<plugin>.json` with it. Keeping the two records genuinely independent
30
+ * is therefore a CLI-level policy, not something this class enforces: `blogwright destroy`
31
+ * is expected to refuse while any `state/<env>.<plugin>.json` exists, naming that plugin's
32
+ * `blogwright <plugin> destroy --yes`, so plugins are always torn down before the site that
33
+ * hosts them. `StateStore` itself is a store, not a policy - it does not add that guard.
14
34
  */
15
35
  export declare class StateStore {
16
36
  private readonly s3;
17
37
  private readonly bucket;
18
38
  private readonly env;
19
- constructor(s3: S3Client, bucket: string, env: string);
39
+ private readonly key;
40
+ constructor(s3: S3Client, bucket: string, env: string, scope?: string);
20
41
  load(): Promise<OpsState>;
21
42
  save(state: OpsState): Promise<void>;
22
43
  delete(): Promise<void>;
package/dist/state.js CHANGED
@@ -1,27 +1,54 @@
1
1
  export function emptyState(env) {
2
2
  return { version: 1, env, updatedAt: undefined, resources: {} };
3
3
  }
4
- function stateKey(env) {
5
- return `state/${env}.json`;
4
+ /** Namespace shape a plugin manifest declares - reused here so a scope can't smuggle a `/` or `..` into the key. */
5
+ const SCOPE_PATTERN = /^[a-z0-9-]+$/;
6
+ function stateKey(env, scope) {
7
+ return scope === undefined ? `state/${env}.json` : `state/${env}.${scope}.json`;
6
8
  }
7
9
  /**
8
- * S3-backed topology state. Lives at `s3://<bucket>/state/<env>.json` so it is shared
9
- * across machines and is the single source of truth for what has been provisioned.
10
+ * S3-backed topology state: the single source of truth for what has been provisioned.
11
+ *
12
+ * An unscoped store (three constructor arguments) keys `s3://<bucket>/state/<env>.json`.
13
+ * This is on-disk identity for every environment that already exists - it must never move,
14
+ * or every existing site's state becomes unreadable.
15
+ *
16
+ * A store scoped to a plugin (the fourth argument) keys
17
+ * `s3://<bucket>/state/<env>.<plugin>.json` instead, so a plugin's resources are recorded
18
+ * separately from the site's: `blogwright <plugin> destroy` never touches, and never
19
+ * discards, the site's own record of what exists.
20
+ *
21
+ * Scoping changes the key, not the bucket - a scoped and an unscoped store for the same
22
+ * environment are constructed over the same `names.bucket` (see
23
+ * `packages/cli/src/context.ts:134`) and both objects live side by side under that
24
+ * bucket's `state/` prefix. That prefix is not itself a safety boundary: the site's own
25
+ * bucket node empties it wholesale before deleting the bucket
26
+ * (`deletePrefix(ctx.names.bucket, '')`, `packages/cli/src/nodes.ts:66`), which would take
27
+ * every `state/<env>.<plugin>.json` with it. Keeping the two records genuinely independent
28
+ * is therefore a CLI-level policy, not something this class enforces: `blogwright destroy`
29
+ * is expected to refuse while any `state/<env>.<plugin>.json` exists, naming that plugin's
30
+ * `blogwright <plugin> destroy --yes`, so plugins are always torn down before the site that
31
+ * hosts them. `StateStore` itself is a store, not a policy - it does not add that guard.
10
32
  */
11
33
  export class StateStore {
12
34
  s3;
13
35
  bucket;
14
36
  env;
15
- constructor(s3, bucket, env) {
37
+ key;
38
+ constructor(s3, bucket, env, scope) {
16
39
  this.s3 = s3;
17
40
  this.bucket = bucket;
18
41
  this.env = env;
42
+ if (scope !== undefined && !SCOPE_PATTERN.test(scope)) {
43
+ throw new Error(`state store scope must be lowercase alphanumeric/dashes, got "${scope}" for s3://${bucket}`);
44
+ }
45
+ this.key = stateKey(env, scope);
19
46
  }
20
47
  async load() {
21
48
  // getObjectText returns undefined only when the object/bucket does not exist (a fresh
22
- // environment). A present-but-corrupt document must NOT be silently treated as empty —
23
- // that would cause duplicate-resource creation — so let a parse error surface.
24
- const text = await this.s3.getObjectText(this.bucket, stateKey(this.env));
49
+ // environment). A present-but-corrupt document must NOT be silently treated as empty -
50
+ // that would cause duplicate-resource creation - so let a parse error surface.
51
+ const text = await this.s3.getObjectText(this.bucket, this.key);
25
52
  // Strictly undefined: a present-but-empty (zero-byte) state object is
26
53
  // corruption, not a fresh environment, and must hit the guard below.
27
54
  if (text === undefined)
@@ -30,14 +57,14 @@ export class StateStore {
30
57
  return JSON.parse(text);
31
58
  }
32
59
  catch (err) {
33
- throw new Error(`state/${this.env}.json in s3://${this.bucket} is not valid JSON — refusing to proceed with empty state`, { cause: err });
60
+ throw new Error(`${this.key} in s3://${this.bucket} is not valid JSON - refusing to proceed with empty state`, { cause: err });
34
61
  }
35
62
  }
36
63
  async save(state) {
37
64
  state.updatedAt = new Date().toISOString();
38
- await this.s3.putObject(this.bucket, stateKey(this.env), JSON.stringify(state, null, 2), 'application/json');
65
+ await this.s3.putObject(this.bucket, this.key, JSON.stringify(state, null, 2), 'application/json');
39
66
  }
40
67
  async delete() {
41
- await this.s3.deleteObject(this.bucket, stateKey(this.env)).catch(() => undefined);
68
+ await this.s3.deleteObject(this.bucket, this.key).catch(() => undefined);
42
69
  }
43
70
  }
package/dist/util.d.ts CHANGED
@@ -1,3 +1,26 @@
1
+ /**
2
+ * The fixed timestamp every reproducible zip in this repo stamps its entries
3
+ * with, so identical input bytes always produce identical archive bytes.
4
+ *
5
+ * **Constructed from local parts on purpose, and the obvious `Date.UTC` form is
6
+ * a bug.** A zip's DOS timestamp is *local* time, and `fflate` reads it with
7
+ * `getFullYear()`/`getMonth()`/`getDate()`, so a `Date` fixed in UTC lands on a
8
+ * different local date in every zone. Two things follow, and this repo shipped
9
+ * both:
10
+ *
11
+ * - `new Date('1980-01-01T00:00:00Z')` is 1979-12-31 local anywhere west of
12
+ * Greenwich, and `fflate` throws `date not in range 1980-2099` outright -
13
+ * so `blogwright bootstrap`, `deploy` and `analytics bootstrap` all failed
14
+ * for most of the Americas while passing in CI, which runs `TZ=UTC`.
15
+ * - Even where it did not throw, the encoded timestamp differed by zone, so
16
+ * the archive was *not* reproducible - the property the constant exists to
17
+ * provide. The crash was hiding that.
18
+ *
19
+ * A local-constructed 1980-01-02 is 1980-01-02 in every zone by construction:
20
+ * in range everywhere, and byte-identical everywhere. The second of January
21
+ * rather than the first so that no offset can push it below the 1980 floor.
22
+ */
23
+ export declare const REPRODUCIBLE_ZIP_MTIME: Date;
1
24
  export declare function sleep(ms: number): Promise<void>;
2
25
  export interface RetryOptions {
3
26
  attempts?: number;
package/dist/util.js CHANGED
@@ -1,4 +1,27 @@
1
1
  import { isRetryable } from './aws/errors.js';
2
+ /**
3
+ * The fixed timestamp every reproducible zip in this repo stamps its entries
4
+ * with, so identical input bytes always produce identical archive bytes.
5
+ *
6
+ * **Constructed from local parts on purpose, and the obvious `Date.UTC` form is
7
+ * a bug.** A zip's DOS timestamp is *local* time, and `fflate` reads it with
8
+ * `getFullYear()`/`getMonth()`/`getDate()`, so a `Date` fixed in UTC lands on a
9
+ * different local date in every zone. Two things follow, and this repo shipped
10
+ * both:
11
+ *
12
+ * - `new Date('1980-01-01T00:00:00Z')` is 1979-12-31 local anywhere west of
13
+ * Greenwich, and `fflate` throws `date not in range 1980-2099` outright -
14
+ * so `blogwright bootstrap`, `deploy` and `analytics bootstrap` all failed
15
+ * for most of the Americas while passing in CI, which runs `TZ=UTC`.
16
+ * - Even where it did not throw, the encoded timestamp differed by zone, so
17
+ * the archive was *not* reproducible - the property the constant exists to
18
+ * provide. The crash was hiding that.
19
+ *
20
+ * A local-constructed 1980-01-02 is 1980-01-02 in every zone by construction:
21
+ * in range everywhere, and byte-identical everywhere. The second of January
22
+ * rather than the first so that no offset can push it below the 1980 floor.
23
+ */
24
+ export const REPRODUCIBLE_ZIP_MTIME = new Date(1980, 0, 2);
2
25
  export function sleep(ms) {
3
26
  return new Promise((resolve) => setTimeout(resolve, ms));
4
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blogwright-core",
3
- "version": "0.3.3",
3
+ "version": "0.4.0-beta.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "files": [