@vibe-hero/server 0.1.0
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/LICENSE +190 -0
- package/README.md +151 -0
- package/dist/catalog/bundled/claude-code/.gitkeep +0 -0
- package/dist/catalog/bundled/claude-code/context-management.yaml +302 -0
- package/dist/catalog/bundled/claude-code/planning.yaml +313 -0
- package/dist/catalog/bundled/claude-code/subagents.yaml +357 -0
- package/dist/catalog/bundled/general/.gitkeep +0 -0
- package/dist/catalog/bundled/general/_placeholder.yaml +39 -0
- package/dist/catalog/bundled/general/task-decomposition.yaml +390 -0
- package/dist/catalog/bundled/index.d.ts +39 -0
- package/dist/catalog/bundled/index.d.ts.map +1 -0
- package/dist/catalog/bundled/index.js +41 -0
- package/dist/catalog/bundled/index.js.map +1 -0
- package/dist/catalog/fetcher.d.ts +201 -0
- package/dist/catalog/fetcher.d.ts.map +1 -0
- package/dist/catalog/fetcher.js +452 -0
- package/dist/catalog/fetcher.js.map +1 -0
- package/dist/catalog/loader.d.ts +165 -0
- package/dist/catalog/loader.d.ts.map +1 -0
- package/dist/catalog/loader.js +241 -0
- package/dist/catalog/loader.js.map +1 -0
- package/dist/catalog/resolve.d.ts +85 -0
- package/dist/catalog/resolve.d.ts.map +1 -0
- package/dist/catalog/resolve.js +103 -0
- package/dist/catalog/resolve.js.map +1 -0
- package/dist/cli/getOffer.d.ts +38 -0
- package/dist/cli/getOffer.d.ts.map +1 -0
- package/dist/cli/getOffer.js +150 -0
- package/dist/cli/getOffer.js.map +1 -0
- package/dist/cli/index.d.ts +46 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +88 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +63 -0
- package/dist/config.js.map +1 -0
- package/dist/engine/elo.d.ts +76 -0
- package/dist/engine/elo.d.ts.map +1 -0
- package/dist/engine/elo.js +79 -0
- package/dist/engine/elo.js.map +1 -0
- package/dist/engine/graduation.d.ts +108 -0
- package/dist/engine/graduation.d.ts.map +1 -0
- package/dist/engine/graduation.js +161 -0
- package/dist/engine/graduation.js.map +1 -0
- package/dist/engine/lapse.d.ts +80 -0
- package/dist/engine/lapse.d.ts.map +1 -0
- package/dist/engine/lapse.js +125 -0
- package/dist/engine/lapse.js.map +1 -0
- package/dist/engine/selection.d.ts +84 -0
- package/dist/engine/selection.d.ts.map +1 -0
- package/dist/engine/selection.js +119 -0
- package/dist/engine/selection.js.map +1 -0
- package/dist/grading/deterministic.d.ts +102 -0
- package/dist/grading/deterministic.d.ts.map +1 -0
- package/dist/grading/deterministic.js +118 -0
- package/dist/grading/deterministic.js.map +1 -0
- package/dist/grading/freeform.d.ts +64 -0
- package/dist/grading/freeform.d.ts.map +1 -0
- package/dist/grading/freeform.js +85 -0
- package/dist/grading/freeform.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/observation/hookEvents.d.ts +113 -0
- package/dist/observation/hookEvents.d.ts.map +1 -0
- package/dist/observation/hookEvents.js +170 -0
- package/dist/observation/hookEvents.js.map +1 -0
- package/dist/observation/offers.d.ts +215 -0
- package/dist/observation/offers.d.ts.map +1 -0
- package/dist/observation/offers.js +327 -0
- package/dist/observation/offers.js.map +1 -0
- package/dist/observation/source.d.ts +133 -0
- package/dist/observation/source.d.ts.map +1 -0
- package/dist/observation/source.js +105 -0
- package/dist/observation/source.js.map +1 -0
- package/dist/profile/migrate.d.ts +122 -0
- package/dist/profile/migrate.d.ts.map +1 -0
- package/dist/profile/migrate.js +147 -0
- package/dist/profile/migrate.js.map +1 -0
- package/dist/profile/store.d.ts +84 -0
- package/dist/profile/store.d.ts.map +1 -0
- package/dist/profile/store.js +267 -0
- package/dist/profile/store.js.map +1 -0
- package/dist/schemas/common.d.ts +95 -0
- package/dist/schemas/common.d.ts.map +1 -0
- package/dist/schemas/common.js +106 -0
- package/dist/schemas/common.js.map +1 -0
- package/dist/schemas/content.d.ts +828 -0
- package/dist/schemas/content.d.ts.map +1 -0
- package/dist/schemas/content.js +219 -0
- package/dist/schemas/content.js.map +1 -0
- package/dist/schemas/profile.d.ts +599 -0
- package/dist/schemas/profile.d.ts.map +1 -0
- package/dist/schemas/profile.js +177 -0
- package/dist/schemas/profile.js.map +1 -0
- package/dist/schemas/tools.d.ts +1581 -0
- package/dist/schemas/tools.d.ts.map +1 -0
- package/dist/schemas/tools.js +286 -0
- package/dist/schemas/tools.js.map +1 -0
- package/dist/tools/config.d.ts +51 -0
- package/dist/tools/config.d.ts.map +1 -0
- package/dist/tools/config.js +104 -0
- package/dist/tools/config.js.map +1 -0
- package/dist/tools/gate.d.ts +50 -0
- package/dist/tools/gate.d.ts.map +1 -0
- package/dist/tools/gate.js +67 -0
- package/dist/tools/gate.js.map +1 -0
- package/dist/tools/guidance.d.ts +36 -0
- package/dist/tools/guidance.d.ts.map +1 -0
- package/dist/tools/guidance.js +117 -0
- package/dist/tools/guidance.js.map +1 -0
- package/dist/tools/listTopics.d.ts +55 -0
- package/dist/tools/listTopics.d.ts.map +1 -0
- package/dist/tools/listTopics.js +78 -0
- package/dist/tools/listTopics.js.map +1 -0
- package/dist/tools/offers.d.ts +60 -0
- package/dist/tools/offers.d.ts.map +1 -0
- package/dist/tools/offers.js +152 -0
- package/dist/tools/offers.js.map +1 -0
- package/dist/tools/placeholders.d.ts +27 -0
- package/dist/tools/placeholders.d.ts.map +1 -0
- package/dist/tools/placeholders.js +49 -0
- package/dist/tools/placeholders.js.map +1 -0
- package/dist/tools/recordObservation.d.ts +52 -0
- package/dist/tools/recordObservation.d.ts.map +1 -0
- package/dist/tools/recordObservation.js +87 -0
- package/dist/tools/recordObservation.js.map +1 -0
- package/dist/tools/startQuiz.d.ts +82 -0
- package/dist/tools/startQuiz.d.ts.map +1 -0
- package/dist/tools/startQuiz.js +180 -0
- package/dist/tools/startQuiz.js.map +1 -0
- package/dist/tools/status.d.ts +59 -0
- package/dist/tools/status.d.ts.map +1 -0
- package/dist/tools/status.js +133 -0
- package/dist/tools/status.js.map +1 -0
- package/dist/tools/submitAnswer.d.ts +156 -0
- package/dist/tools/submitAnswer.d.ts.map +1 -0
- package/dist/tools/submitAnswer.js +402 -0
- package/dist/tools/submitAnswer.js.map +1 -0
- package/dist/tools/types.d.ts +82 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +48 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/us2/standing.d.ts +111 -0
- package/dist/tools/us2/standing.d.ts.map +1 -0
- package/dist/tools/us2/standing.js +143 -0
- package/dist/tools/us2/standing.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Profile schema-version / migration policy (T056, analyze finding E6).
|
|
3
|
+
*
|
|
4
|
+
* Centralizes how a persisted {@link Profile} of *some* on-disk schema version is
|
|
5
|
+
* brought up to the version this build understands ({@link CURRENT_PROFILE_SCHEMA_VERSION}).
|
|
6
|
+
* Before this module the only version handling was an implicit Zod `safeParse`
|
|
7
|
+
* inside the store; there was no explicit forward/back-compat policy, so a
|
|
8
|
+
* profile written by a *newer* build would simply fail validation and be treated
|
|
9
|
+
* as "corrupt" — silently discarding data we don't understand (E6).
|
|
10
|
+
*
|
|
11
|
+
* The policy, expressed as one function ({@link migrateProfile}), is:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Same version, valid** → pass through unchanged (`migrated: false`).
|
|
14
|
+
* 2. **Older known version** → run the ordered {@link MIGRATIONS} steps from the
|
|
15
|
+
* on-disk version up to current, then validate (`migrated: true`). For v1
|
|
16
|
+
* there is exactly one version so the registry is empty (a documented stub);
|
|
17
|
+
* the SHAPE exists so future `v1 → v2` etc. steps slot in without touching
|
|
18
|
+
* callers. Additive fields already default via Zod (e.g. `dwell`,
|
|
19
|
+
* `candidateKeys`), so a forward-compatible additive change needs no step at
|
|
20
|
+
* all — the registry is for *structural* migrations that Zod defaults can't
|
|
21
|
+
* express.
|
|
22
|
+
* 3. **Newer (future) version** → do NOT crash and do NOT discard (FR-023
|
|
23
|
+
* "tolerate gracefully"). We can't safely read a shape this build predates,
|
|
24
|
+
* so we fall back to a fresh empty profile for *this* run and signal
|
|
25
|
+
* `reset: true` with `preserved: true`. The store leaves the on-disk file
|
|
26
|
+
* untouched — overwriting a newer profile we don't understand would destroy
|
|
27
|
+
* a forward-compatible user's data, so we explicitly refuse to.
|
|
28
|
+
* 4. **Corrupt / invalid** (bad shape at the resolved version) → fresh empty
|
|
29
|
+
* profile (`reset: true`), consistent with the store's prior behavior of
|
|
30
|
+
* degrading an unreadable file to {@link emptyProfile}.
|
|
31
|
+
*
|
|
32
|
+
* `migrateProfile` is pure (no IO): the store decides what to persist based on
|
|
33
|
+
* the returned flags. This keeps the version policy unit-testable in isolation.
|
|
34
|
+
*
|
|
35
|
+
* Source of truth: specs/001-vibe-hero-mvp/spec.md (FR-023), data-model.md
|
|
36
|
+
* (§ Profile `schemaVersion` "migration guard"), tasks.md T056.
|
|
37
|
+
*/
|
|
38
|
+
import { type Profile } from "../schemas/profile.js";
|
|
39
|
+
/**
|
|
40
|
+
* The profile schema version this build understands. Centralized here (re-exported
|
|
41
|
+
* from the single source, {@link PROFILE_SCHEMA_VERSION}) so version policy lives
|
|
42
|
+
* in one place; the store and tests import it from this module.
|
|
43
|
+
*/
|
|
44
|
+
export declare const CURRENT_PROFILE_SCHEMA_VERSION = 1;
|
|
45
|
+
/**
|
|
46
|
+
* A single forward migration step: transforms a parsed profile document from
|
|
47
|
+
* `from` to `from + 1`. Steps operate on `unknown` (the raw parsed JSON, not a
|
|
48
|
+
* validated {@link Profile}) because an older document is, by definition, NOT yet
|
|
49
|
+
* shaped like the *current* schema — the step's job is to reshape it. The final
|
|
50
|
+
* shape is validated once, after all steps run.
|
|
51
|
+
*/
|
|
52
|
+
export interface MigrationStep {
|
|
53
|
+
/** The schema version this step upgrades FROM (it produces `from + 1`). */
|
|
54
|
+
readonly from: number;
|
|
55
|
+
/** Pure transform of the raw document toward the next version. */
|
|
56
|
+
readonly up: (raw: Record<string, unknown>) => Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ordered registry of forward migration steps, keyed by their `from` version.
|
|
60
|
+
*
|
|
61
|
+
* **v1 is the only version**, so this is intentionally empty — a documented stub.
|
|
62
|
+
* The registry SHAPE exists so a future schema bump is purely additive here:
|
|
63
|
+
* add a `{ from: 1, up }` step that reshapes a v1 document into v2 and bump
|
|
64
|
+
* {@link CURRENT_PROFILE_SCHEMA_VERSION} (via the source `PROFILE_SCHEMA_VERSION`).
|
|
65
|
+
* {@link migrateProfile} will then automatically chain `from` → current.
|
|
66
|
+
*
|
|
67
|
+
* Note: purely *additive* fields that a new field's Zod `.default(...)` can fill
|
|
68
|
+
* (the pattern already used for `dwell` and `candidateKeys`) need NO step — an old
|
|
69
|
+
* document validates forward for free. Steps are reserved for structural changes
|
|
70
|
+
* (renames, splits, type changes) Zod defaults cannot express.
|
|
71
|
+
*/
|
|
72
|
+
export declare const MIGRATIONS: readonly MigrationStep[];
|
|
73
|
+
/** Result of {@link migrateProfile}. Discriminated on `reset`. */
|
|
74
|
+
export type MigrateResult = {
|
|
75
|
+
/** No reset: the document was usable at (or migrated up to) current. */
|
|
76
|
+
readonly reset: false;
|
|
77
|
+
/** The validated, current-version profile. */
|
|
78
|
+
readonly profile: Profile;
|
|
79
|
+
/** `true` iff at least one migration step ran (older → current). */
|
|
80
|
+
readonly migrated: boolean;
|
|
81
|
+
} | {
|
|
82
|
+
/** Reset: the document was unreadable at the current version. */
|
|
83
|
+
readonly reset: true;
|
|
84
|
+
/** A fresh empty profile to use for this run. */
|
|
85
|
+
readonly profile: Profile;
|
|
86
|
+
/**
|
|
87
|
+
* `true` when the reset is because the document is a NEWER version than
|
|
88
|
+
* this build understands — the on-disk file MUST be preserved (not
|
|
89
|
+
* overwritten). `false` for a corrupt/invalid document, which the store
|
|
90
|
+
* may overwrite on its next write as before.
|
|
91
|
+
*/
|
|
92
|
+
readonly preserved: boolean;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Dependency-injection seam for {@link migrateProfile}. Both fields default to
|
|
96
|
+
* the shipped values; they exist ONLY so the older→migrated path is genuinely
|
|
97
|
+
* testable while v1 is the only real version (and so future contributors can
|
|
98
|
+
* unit-test a `v2`/`v3` step in isolation before wiring it in). Production
|
|
99
|
+
* callers pass nothing.
|
|
100
|
+
*/
|
|
101
|
+
export interface MigrateOptions {
|
|
102
|
+
/** Override the schema version this run targets (defaults to current). */
|
|
103
|
+
readonly currentVersion?: number;
|
|
104
|
+
/** Override the migration registry (defaults to {@link MIGRATIONS}). */
|
|
105
|
+
readonly migrations?: readonly MigrationStep[];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Route a parsed-JSON profile document of unknown schema version through the
|
|
109
|
+
* forward/back-compat policy documented at the top of this file.
|
|
110
|
+
*
|
|
111
|
+
* Pure: performs no IO and never throws for *data* reasons (only the internal
|
|
112
|
+
* programming-error guard in {@link applyMigrations} can throw, and only if the
|
|
113
|
+
* registry is left inconsistent with the current version). The store interprets
|
|
114
|
+
* the result's flags to decide whether to preserve or overwrite the file.
|
|
115
|
+
*
|
|
116
|
+
* @param raw - The already-parsed JSON of the on-disk profile (not the raw text).
|
|
117
|
+
* @param options - Test/forward-dev seam; production callers omit it.
|
|
118
|
+
* @returns A {@link MigrateResult}: pass-through, migrated, or reset (corrupt vs.
|
|
119
|
+
* newer-preserved).
|
|
120
|
+
*/
|
|
121
|
+
export declare const migrateProfile: (raw: unknown, options?: MigrateOptions) => MigrateResult;
|
|
122
|
+
//# sourceMappingURL=migrate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../../src/profile/migrate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,EAIL,KAAK,OAAO,EACb,MAAM,uBAAuB,CAAC;AAE/B;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,IAAyB,CAAC;AAErE;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,kEAAkE;IAClE,QAAQ,CAAC,EAAE,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxE;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,UAAU,EAAE,SAAS,aAAa,EAAO,CAAC;AAEvD,kEAAkE;AAClE,MAAM,MAAM,aAAa,GACrB;IACE,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,8CAA8C;IAC9C,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B,GACD;IACE,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC;IACrB,iDAAiD;IACjD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B,CAAC;AA2CN;;;;;;GAMG;AACH,MAAM,WAAW,cAAc;IAC7B,0EAA0E;IAC1E,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,wEAAwE;IACxE,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;CAChD;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,GACzB,KAAK,OAAO,EACZ,UAAS,cAAmB,KAC3B,aAgDF,CAAC"}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Profile schema-version / migration policy (T056, analyze finding E6).
|
|
3
|
+
*
|
|
4
|
+
* Centralizes how a persisted {@link Profile} of *some* on-disk schema version is
|
|
5
|
+
* brought up to the version this build understands ({@link CURRENT_PROFILE_SCHEMA_VERSION}).
|
|
6
|
+
* Before this module the only version handling was an implicit Zod `safeParse`
|
|
7
|
+
* inside the store; there was no explicit forward/back-compat policy, so a
|
|
8
|
+
* profile written by a *newer* build would simply fail validation and be treated
|
|
9
|
+
* as "corrupt" — silently discarding data we don't understand (E6).
|
|
10
|
+
*
|
|
11
|
+
* The policy, expressed as one function ({@link migrateProfile}), is:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Same version, valid** → pass through unchanged (`migrated: false`).
|
|
14
|
+
* 2. **Older known version** → run the ordered {@link MIGRATIONS} steps from the
|
|
15
|
+
* on-disk version up to current, then validate (`migrated: true`). For v1
|
|
16
|
+
* there is exactly one version so the registry is empty (a documented stub);
|
|
17
|
+
* the SHAPE exists so future `v1 → v2` etc. steps slot in without touching
|
|
18
|
+
* callers. Additive fields already default via Zod (e.g. `dwell`,
|
|
19
|
+
* `candidateKeys`), so a forward-compatible additive change needs no step at
|
|
20
|
+
* all — the registry is for *structural* migrations that Zod defaults can't
|
|
21
|
+
* express.
|
|
22
|
+
* 3. **Newer (future) version** → do NOT crash and do NOT discard (FR-023
|
|
23
|
+
* "tolerate gracefully"). We can't safely read a shape this build predates,
|
|
24
|
+
* so we fall back to a fresh empty profile for *this* run and signal
|
|
25
|
+
* `reset: true` with `preserved: true`. The store leaves the on-disk file
|
|
26
|
+
* untouched — overwriting a newer profile we don't understand would destroy
|
|
27
|
+
* a forward-compatible user's data, so we explicitly refuse to.
|
|
28
|
+
* 4. **Corrupt / invalid** (bad shape at the resolved version) → fresh empty
|
|
29
|
+
* profile (`reset: true`), consistent with the store's prior behavior of
|
|
30
|
+
* degrading an unreadable file to {@link emptyProfile}.
|
|
31
|
+
*
|
|
32
|
+
* `migrateProfile` is pure (no IO): the store decides what to persist based on
|
|
33
|
+
* the returned flags. This keeps the version policy unit-testable in isolation.
|
|
34
|
+
*
|
|
35
|
+
* Source of truth: specs/001-vibe-hero-mvp/spec.md (FR-023), data-model.md
|
|
36
|
+
* (§ Profile `schemaVersion` "migration guard"), tasks.md T056.
|
|
37
|
+
*/
|
|
38
|
+
import { ProfileSchema, emptyProfile, PROFILE_SCHEMA_VERSION, } from "../schemas/profile.js";
|
|
39
|
+
/**
|
|
40
|
+
* The profile schema version this build understands. Centralized here (re-exported
|
|
41
|
+
* from the single source, {@link PROFILE_SCHEMA_VERSION}) so version policy lives
|
|
42
|
+
* in one place; the store and tests import it from this module.
|
|
43
|
+
*/
|
|
44
|
+
export const CURRENT_PROFILE_SCHEMA_VERSION = PROFILE_SCHEMA_VERSION;
|
|
45
|
+
/**
|
|
46
|
+
* Ordered registry of forward migration steps, keyed by their `from` version.
|
|
47
|
+
*
|
|
48
|
+
* **v1 is the only version**, so this is intentionally empty — a documented stub.
|
|
49
|
+
* The registry SHAPE exists so a future schema bump is purely additive here:
|
|
50
|
+
* add a `{ from: 1, up }` step that reshapes a v1 document into v2 and bump
|
|
51
|
+
* {@link CURRENT_PROFILE_SCHEMA_VERSION} (via the source `PROFILE_SCHEMA_VERSION`).
|
|
52
|
+
* {@link migrateProfile} will then automatically chain `from` → current.
|
|
53
|
+
*
|
|
54
|
+
* Note: purely *additive* fields that a new field's Zod `.default(...)` can fill
|
|
55
|
+
* (the pattern already used for `dwell` and `candidateKeys`) need NO step — an old
|
|
56
|
+
* document validates forward for free. Steps are reserved for structural changes
|
|
57
|
+
* (renames, splits, type changes) Zod defaults cannot express.
|
|
58
|
+
*/
|
|
59
|
+
export const MIGRATIONS = [];
|
|
60
|
+
/**
|
|
61
|
+
* Read the declared `schemaVersion` from a parsed-JSON document, or `undefined`
|
|
62
|
+
* if the value is missing or not a positive integer. We read it leniently (not
|
|
63
|
+
* via the full {@link ProfileSchema}) precisely so we can route a *newer* document
|
|
64
|
+
* to the tolerate path rather than failing it as "corrupt".
|
|
65
|
+
*/
|
|
66
|
+
const readDeclaredVersion = (raw) => {
|
|
67
|
+
if (typeof raw !== "object" || raw === null)
|
|
68
|
+
return undefined;
|
|
69
|
+
const v = raw.schemaVersion;
|
|
70
|
+
return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Apply migration steps in order from `fromVersion` up to `currentVersion`,
|
|
74
|
+
* picking each step from `migrations`. Returns the reshaped raw document and
|
|
75
|
+
* whether any step actually ran. Throws if a required step is missing (a
|
|
76
|
+
* programming error — a known-older version with a gap in the registry).
|
|
77
|
+
*/
|
|
78
|
+
const applyMigrations = (raw, fromVersion, currentVersion, migrations) => {
|
|
79
|
+
let doc = raw;
|
|
80
|
+
let migrated = false;
|
|
81
|
+
for (let v = fromVersion; v < currentVersion; v++) {
|
|
82
|
+
const step = migrations.find((s) => s.from === v);
|
|
83
|
+
if (step === undefined) {
|
|
84
|
+
throw new Error(`no migration step registered for profile schema v${v} → v${v + 1}`);
|
|
85
|
+
}
|
|
86
|
+
doc = step.up(doc);
|
|
87
|
+
migrated = true;
|
|
88
|
+
}
|
|
89
|
+
// Stamp the version the document now conforms to so the post-migration
|
|
90
|
+
// validation sees the current version regardless of what each step set.
|
|
91
|
+
return { doc: { ...doc, schemaVersion: currentVersion }, migrated };
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Route a parsed-JSON profile document of unknown schema version through the
|
|
95
|
+
* forward/back-compat policy documented at the top of this file.
|
|
96
|
+
*
|
|
97
|
+
* Pure: performs no IO and never throws for *data* reasons (only the internal
|
|
98
|
+
* programming-error guard in {@link applyMigrations} can throw, and only if the
|
|
99
|
+
* registry is left inconsistent with the current version). The store interprets
|
|
100
|
+
* the result's flags to decide whether to preserve or overwrite the file.
|
|
101
|
+
*
|
|
102
|
+
* @param raw - The already-parsed JSON of the on-disk profile (not the raw text).
|
|
103
|
+
* @param options - Test/forward-dev seam; production callers omit it.
|
|
104
|
+
* @returns A {@link MigrateResult}: pass-through, migrated, or reset (corrupt vs.
|
|
105
|
+
* newer-preserved).
|
|
106
|
+
*/
|
|
107
|
+
export const migrateProfile = (raw, options = {}) => {
|
|
108
|
+
const currentVersion = options.currentVersion ?? CURRENT_PROFILE_SCHEMA_VERSION;
|
|
109
|
+
const migrations = options.migrations ?? MIGRATIONS;
|
|
110
|
+
const declared = readDeclaredVersion(raw);
|
|
111
|
+
// --- newer than we understand → tolerate, preserve on disk (FR-023) -------
|
|
112
|
+
if (declared !== undefined && declared > currentVersion) {
|
|
113
|
+
process.stderr.write(`vibe-hero: profile schema v${declared} is newer than this build supports ` +
|
|
114
|
+
`(v${currentVersion}); using a temporary empty profile and ` +
|
|
115
|
+
`leaving the existing file untouched to avoid downgrading it.\n`);
|
|
116
|
+
return { reset: true, profile: emptyProfile(), preserved: true };
|
|
117
|
+
}
|
|
118
|
+
// --- known version (current or older) → migrate then validate -------------
|
|
119
|
+
if (declared !== undefined) {
|
|
120
|
+
let result;
|
|
121
|
+
try {
|
|
122
|
+
result = applyMigrations(raw, declared, currentVersion, migrations);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// A registry gap for a known-older version: treat as unreadable rather
|
|
126
|
+
// than crash the server (consistent with the corrupt-file degrade path).
|
|
127
|
+
return { reset: true, profile: emptyProfile(), preserved: false };
|
|
128
|
+
}
|
|
129
|
+
const parsed = ProfileSchema.safeParse(result.doc);
|
|
130
|
+
if (parsed.success) {
|
|
131
|
+
return { reset: false, profile: parsed.data, migrated: result.migrated };
|
|
132
|
+
}
|
|
133
|
+
// Migrated/same-version doc that still doesn't validate → corrupt.
|
|
134
|
+
return { reset: true, profile: emptyProfile(), preserved: false };
|
|
135
|
+
}
|
|
136
|
+
// --- no usable version (missing field / not an object / corrupt) ----------
|
|
137
|
+
// Last-chance validation: a well-formed current-version doc whose schemaVersion
|
|
138
|
+
// somehow read as non-positive would have been caught above; anything here is
|
|
139
|
+
// genuinely unreadable. Try a direct parse so a structurally-valid doc isn't
|
|
140
|
+
// discarded on a version-read technicality, then degrade to empty.
|
|
141
|
+
const parsed = ProfileSchema.safeParse(raw);
|
|
142
|
+
if (parsed.success) {
|
|
143
|
+
return { reset: false, profile: parsed.data, migrated: false };
|
|
144
|
+
}
|
|
145
|
+
return { reset: true, profile: emptyProfile(), preserved: false };
|
|
146
|
+
};
|
|
147
|
+
//# sourceMappingURL=migrate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrate.js","sourceRoot":"","sources":["../../src/profile/migrate.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Learner-profile persistence (T013).
|
|
3
|
+
*
|
|
4
|
+
* The profile is a single Zod-validated JSON document at
|
|
5
|
+
* `${VIBE_HERO_HOME}/profile.json` (default `~/.vibe-hero/`). It may be written
|
|
6
|
+
* by multiple concurrent host sessions, so every mutation is **atomic and
|
|
7
|
+
* serialized**: writes go through a temp file + `fs.rename` under an advisory
|
|
8
|
+
* lock acquired via `proper-lockfile` (FR-023a). Reads never throw — a missing,
|
|
9
|
+
* unreadable, or corrupt file degrades to a fresh empty profile (FR-023).
|
|
10
|
+
*
|
|
11
|
+
* The fs/lock boundary is intentionally thin; everything else is pure data
|
|
12
|
+
* shuffling around {@link ProfileSchema}.
|
|
13
|
+
*
|
|
14
|
+
* Source of truth: specs/001-vibe-hero-mvp/data-model.md (§ Storage notes),
|
|
15
|
+
* spec.md FR-022 / FR-023 / FR-023a.
|
|
16
|
+
*/
|
|
17
|
+
import { type Profile } from "../schemas/profile.js";
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the directory that holds the profile document.
|
|
20
|
+
*
|
|
21
|
+
* @param dirOverride - Explicit directory (test seam). When omitted, falls back
|
|
22
|
+
* to `VIBE_HERO_HOME`, then to `~/.vibe-hero`.
|
|
23
|
+
* @returns Absolute path to the profile directory.
|
|
24
|
+
*/
|
|
25
|
+
export declare const profileDir: (dirOverride?: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the absolute path to the profile JSON document.
|
|
28
|
+
*
|
|
29
|
+
* @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
|
|
30
|
+
* @returns Absolute path to `profile.json`.
|
|
31
|
+
*/
|
|
32
|
+
export declare const profilePath: (dirOverride?: string) => string;
|
|
33
|
+
/**
|
|
34
|
+
* Read and validate the profile.
|
|
35
|
+
*
|
|
36
|
+
* Never throws: a missing, unreadable, or schema-invalid file degrades to a
|
|
37
|
+
* fresh {@link emptyProfile} so the server can always operate (FR-023). Version
|
|
38
|
+
* handling is centralized in {@link migrateProfile} (T056): a same-version doc
|
|
39
|
+
* passes through, an older known version is migrated up, and a *newer* version
|
|
40
|
+
* (written by a future build) degrades to an empty profile for this run WITHOUT
|
|
41
|
+
* overwriting the file — `loadProfile` never writes, so a forward-compatible
|
|
42
|
+
* profile we don't understand is preserved on disk. A corrupt file is logged to
|
|
43
|
+
* stderr (without printing its contents — it may hold profile data) and left on
|
|
44
|
+
* disk untouched; the next successful {@link saveProfile} / {@link updateProfile}
|
|
45
|
+
* overwrites it.
|
|
46
|
+
*
|
|
47
|
+
* @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
|
|
48
|
+
* @returns The validated profile, or an empty profile on any failure.
|
|
49
|
+
*/
|
|
50
|
+
export declare const loadProfile: (dirOverride?: string) => Promise<Profile>;
|
|
51
|
+
/**
|
|
52
|
+
* Persist a profile atomically and serialized (FR-023a).
|
|
53
|
+
*
|
|
54
|
+
* Ensures the directory exists, acquires the advisory lock, writes to a unique
|
|
55
|
+
* temp file in the same directory, then `fs.rename`s it over `profile.json`
|
|
56
|
+
* (atomic on POSIX since both paths share a filesystem). `updatedAt` is bumped
|
|
57
|
+
* to now. The lock is always released, even on failure.
|
|
58
|
+
*
|
|
59
|
+
* Prefer {@link updateProfile} for read-modify-write mutations — calling
|
|
60
|
+
* {@link loadProfile} then `saveProfile` is racy (the load happens outside the
|
|
61
|
+
* lock and a concurrent writer can interleave).
|
|
62
|
+
*
|
|
63
|
+
* @param profile - The profile to persist.
|
|
64
|
+
* @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
|
|
65
|
+
*/
|
|
66
|
+
export declare const saveProfile: (profile: Profile, dirOverride?: string) => Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Read-modify-write the profile under the lock so concurrent updaters serialize
|
|
69
|
+
* and no update is lost (FR-023a / edge case E1). This is the primary mutation
|
|
70
|
+
* API.
|
|
71
|
+
*
|
|
72
|
+
* The sequence is: acquire lock → read the *current* on-disk profile (or empty
|
|
73
|
+
* if missing/corrupt) → apply `fn` → atomic write → release. Because both the
|
|
74
|
+
* read and the write happen inside the same lock, two concurrent callers run
|
|
75
|
+
* strictly one-after-another and each sees the other's committed result.
|
|
76
|
+
*
|
|
77
|
+
* @param fn - Pure-ish transform from the current profile to the next one. May
|
|
78
|
+
* be async. It should return a new profile object rather than mutating the
|
|
79
|
+
* argument, though either works since the result is what gets written.
|
|
80
|
+
* @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
|
|
81
|
+
* @returns The profile that was written (with `updatedAt` bumped).
|
|
82
|
+
*/
|
|
83
|
+
export declare const updateProfile: (fn: (current: Profile) => Profile | Promise<Profile>, dirOverride?: string) => Promise<Profile>;
|
|
84
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/profile/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAQH,OAAO,EAA+B,KAAK,OAAO,EAAE,MAAM,uBAAuB,CAAC;AA+BlF;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,GAAI,cAAc,MAAM,KAAG,MAKjD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,cAAc,MAAM,KAAG,MACG,CAAC;AAEvD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,WAAW,GAAU,cAAc,MAAM,KAAG,OAAO,CAAC,OAAO,CAkCvE,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW,GACtB,SAAS,OAAO,EAChB,cAAc,MAAM,KACnB,OAAO,CAAC,IAAI,CASd,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,aAAa,GACxB,IAAI,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,EACpD,cAAc,MAAM,KACnB,OAAO,CAAC,OAAO,CAWjB,CAAC"}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Learner-profile persistence (T013).
|
|
3
|
+
*
|
|
4
|
+
* The profile is a single Zod-validated JSON document at
|
|
5
|
+
* `${VIBE_HERO_HOME}/profile.json` (default `~/.vibe-hero/`). It may be written
|
|
6
|
+
* by multiple concurrent host sessions, so every mutation is **atomic and
|
|
7
|
+
* serialized**: writes go through a temp file + `fs.rename` under an advisory
|
|
8
|
+
* lock acquired via `proper-lockfile` (FR-023a). Reads never throw — a missing,
|
|
9
|
+
* unreadable, or corrupt file degrades to a fresh empty profile (FR-023).
|
|
10
|
+
*
|
|
11
|
+
* The fs/lock boundary is intentionally thin; everything else is pure data
|
|
12
|
+
* shuffling around {@link ProfileSchema}.
|
|
13
|
+
*
|
|
14
|
+
* Source of truth: specs/001-vibe-hero-mvp/data-model.md (§ Storage notes),
|
|
15
|
+
* spec.md FR-022 / FR-023 / FR-023a.
|
|
16
|
+
*/
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import * as fs from "node:fs/promises";
|
|
21
|
+
import * as lockfile from "proper-lockfile";
|
|
22
|
+
import { ProfileSchema, emptyProfile } from "../schemas/profile.js";
|
|
23
|
+
import { migrateProfile } from "./migrate.js";
|
|
24
|
+
/** Basename of the profile document within the profile directory. */
|
|
25
|
+
const PROFILE_FILENAME = "profile.json";
|
|
26
|
+
/** Default profile home when `VIBE_HERO_HOME` is unset (`~/.vibe-hero`). */
|
|
27
|
+
const DEFAULT_DIRNAME = ".vibe-hero";
|
|
28
|
+
/**
|
|
29
|
+
* Lock-acquisition tuning. `proper-lockfile` treats the lock as *stale* after
|
|
30
|
+
* `stale` ms (so a crashed writer can never wedge the profile forever), and a
|
|
31
|
+
* contending writer retries with exponential backoff up to `retries.retries`
|
|
32
|
+
* times. The window is generous enough to serialize bursts of concurrent
|
|
33
|
+
* `updateProfile` calls (FR-023a / edge case E1) without spuriously failing.
|
|
34
|
+
*/
|
|
35
|
+
const LOCK_OPTIONS = {
|
|
36
|
+
stale: 15_000,
|
|
37
|
+
// Resolve against the literal path we pass; the file is guaranteed to exist
|
|
38
|
+
// before we lock (see `acquireLock`), but skipping realpath avoids symlink
|
|
39
|
+
// surprises (e.g. macOS `/var` → `/private/var` under os.tmpdir()).
|
|
40
|
+
realpath: false,
|
|
41
|
+
retries: {
|
|
42
|
+
retries: 50,
|
|
43
|
+
factor: 1.5,
|
|
44
|
+
minTimeout: 10,
|
|
45
|
+
maxTimeout: 200,
|
|
46
|
+
randomize: true,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the directory that holds the profile document.
|
|
51
|
+
*
|
|
52
|
+
* @param dirOverride - Explicit directory (test seam). When omitted, falls back
|
|
53
|
+
* to `VIBE_HERO_HOME`, then to `~/.vibe-hero`.
|
|
54
|
+
* @returns Absolute path to the profile directory.
|
|
55
|
+
*/
|
|
56
|
+
export const profileDir = (dirOverride) => {
|
|
57
|
+
if (dirOverride !== undefined && dirOverride !== "")
|
|
58
|
+
return path.resolve(dirOverride);
|
|
59
|
+
const fromEnv = process.env["VIBE_HERO_HOME"];
|
|
60
|
+
if (fromEnv !== undefined && fromEnv !== "")
|
|
61
|
+
return path.resolve(fromEnv);
|
|
62
|
+
return path.join(homedir(), DEFAULT_DIRNAME);
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the absolute path to the profile JSON document.
|
|
66
|
+
*
|
|
67
|
+
* @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
|
|
68
|
+
* @returns Absolute path to `profile.json`.
|
|
69
|
+
*/
|
|
70
|
+
export const profilePath = (dirOverride) => path.join(profileDir(dirOverride), PROFILE_FILENAME);
|
|
71
|
+
/**
|
|
72
|
+
* Read and validate the profile.
|
|
73
|
+
*
|
|
74
|
+
* Never throws: a missing, unreadable, or schema-invalid file degrades to a
|
|
75
|
+
* fresh {@link emptyProfile} so the server can always operate (FR-023). Version
|
|
76
|
+
* handling is centralized in {@link migrateProfile} (T056): a same-version doc
|
|
77
|
+
* passes through, an older known version is migrated up, and a *newer* version
|
|
78
|
+
* (written by a future build) degrades to an empty profile for this run WITHOUT
|
|
79
|
+
* overwriting the file — `loadProfile` never writes, so a forward-compatible
|
|
80
|
+
* profile we don't understand is preserved on disk. A corrupt file is logged to
|
|
81
|
+
* stderr (without printing its contents — it may hold profile data) and left on
|
|
82
|
+
* disk untouched; the next successful {@link saveProfile} / {@link updateProfile}
|
|
83
|
+
* overwrites it.
|
|
84
|
+
*
|
|
85
|
+
* @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
|
|
86
|
+
* @returns The validated profile, or an empty profile on any failure.
|
|
87
|
+
*/
|
|
88
|
+
export const loadProfile = async (dirOverride) => {
|
|
89
|
+
const file = profilePath(dirOverride);
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = await fs.readFile(file, "utf8");
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
// ENOENT is the normal first-run path; anything else (perms, etc.) is also
|
|
96
|
+
// non-fatal — we still hand back a usable empty profile.
|
|
97
|
+
if (!isNotFound(err)) {
|
|
98
|
+
process.stderr.write(`vibe-hero: could not read profile at ${file}; starting from an empty profile.\n`);
|
|
99
|
+
}
|
|
100
|
+
return emptyProfile();
|
|
101
|
+
}
|
|
102
|
+
const json = parseJson(raw);
|
|
103
|
+
if (json === undefined) {
|
|
104
|
+
process.stderr.write(`vibe-hero: profile at ${file} is corrupt or invalid; starting from an empty profile (file left untouched).\n`);
|
|
105
|
+
return emptyProfile();
|
|
106
|
+
}
|
|
107
|
+
// Centralized version policy (T056). migrateProfile owns same/older/newer
|
|
108
|
+
// routing and emits its own newer-version warning. We only add a stderr note
|
|
109
|
+
// for the corrupt-at-version case to preserve the prior diagnostic.
|
|
110
|
+
const result = migrateProfile(json);
|
|
111
|
+
if (result.reset && !result.preserved) {
|
|
112
|
+
process.stderr.write(`vibe-hero: profile at ${file} is corrupt or invalid; starting from an empty profile (file left untouched).\n`);
|
|
113
|
+
}
|
|
114
|
+
return result.profile;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Persist a profile atomically and serialized (FR-023a).
|
|
118
|
+
*
|
|
119
|
+
* Ensures the directory exists, acquires the advisory lock, writes to a unique
|
|
120
|
+
* temp file in the same directory, then `fs.rename`s it over `profile.json`
|
|
121
|
+
* (atomic on POSIX since both paths share a filesystem). `updatedAt` is bumped
|
|
122
|
+
* to now. The lock is always released, even on failure.
|
|
123
|
+
*
|
|
124
|
+
* Prefer {@link updateProfile} for read-modify-write mutations — calling
|
|
125
|
+
* {@link loadProfile} then `saveProfile` is racy (the load happens outside the
|
|
126
|
+
* lock and a concurrent writer can interleave).
|
|
127
|
+
*
|
|
128
|
+
* @param profile - The profile to persist.
|
|
129
|
+
* @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
|
|
130
|
+
*/
|
|
131
|
+
export const saveProfile = async (profile, dirOverride) => {
|
|
132
|
+
const dir = profileDir(dirOverride);
|
|
133
|
+
const file = path.join(dir, PROFILE_FILENAME);
|
|
134
|
+
const release = await acquireLock(dir, file);
|
|
135
|
+
try {
|
|
136
|
+
await writeAtomic(dir, file, profile);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await release();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* Read-modify-write the profile under the lock so concurrent updaters serialize
|
|
144
|
+
* and no update is lost (FR-023a / edge case E1). This is the primary mutation
|
|
145
|
+
* API.
|
|
146
|
+
*
|
|
147
|
+
* The sequence is: acquire lock → read the *current* on-disk profile (or empty
|
|
148
|
+
* if missing/corrupt) → apply `fn` → atomic write → release. Because both the
|
|
149
|
+
* read and the write happen inside the same lock, two concurrent callers run
|
|
150
|
+
* strictly one-after-another and each sees the other's committed result.
|
|
151
|
+
*
|
|
152
|
+
* @param fn - Pure-ish transform from the current profile to the next one. May
|
|
153
|
+
* be async. It should return a new profile object rather than mutating the
|
|
154
|
+
* argument, though either works since the result is what gets written.
|
|
155
|
+
* @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
|
|
156
|
+
* @returns The profile that was written (with `updatedAt` bumped).
|
|
157
|
+
*/
|
|
158
|
+
export const updateProfile = async (fn, dirOverride) => {
|
|
159
|
+
const dir = profileDir(dirOverride);
|
|
160
|
+
const file = path.join(dir, PROFILE_FILENAME);
|
|
161
|
+
const release = await acquireLock(dir, file);
|
|
162
|
+
try {
|
|
163
|
+
const current = await readUnderLock(file);
|
|
164
|
+
const next = await fn(current);
|
|
165
|
+
return await writeAtomic(dir, file, next);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
await release();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
// --- internals (thin fs/lock boundary) ------------------------------------
|
|
172
|
+
/**
|
|
173
|
+
* Acquire the advisory lock for the profile file.
|
|
174
|
+
*
|
|
175
|
+
* `proper-lockfile` locks by creating `${file}.lock`, but it requires the
|
|
176
|
+
* target path to exist first, so we `mkdir -p` the directory and ensure
|
|
177
|
+
* `profile.json` is present (an empty placeholder is fine — it will be
|
|
178
|
+
* overwritten by the atomic rename, and `readUnderLock` tolerates empty/invalid
|
|
179
|
+
* content).
|
|
180
|
+
*/
|
|
181
|
+
const acquireLock = async (dir, file) => {
|
|
182
|
+
await fs.mkdir(dir, { recursive: true });
|
|
183
|
+
await ensureExists(file);
|
|
184
|
+
return lockfile.lock(file, LOCK_OPTIONS);
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* Read the on-disk profile while holding the lock, degrading to an empty
|
|
188
|
+
* profile if the file is missing, unreadable, or invalid. Unlike
|
|
189
|
+
* {@link loadProfile} this stays quiet (no stderr) — it runs inside the
|
|
190
|
+
* write path where a fresh-start is expected on first write. Version handling
|
|
191
|
+
* goes through the same {@link migrateProfile} policy so an older profile is
|
|
192
|
+
* migrated forward before a read-modify-write, and a newer one degrades to
|
|
193
|
+
* empty (the subsequent write is the caller's explicit choice).
|
|
194
|
+
*/
|
|
195
|
+
const readUnderLock = async (file) => {
|
|
196
|
+
let raw;
|
|
197
|
+
try {
|
|
198
|
+
raw = await fs.readFile(file, "utf8");
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return emptyProfile();
|
|
202
|
+
}
|
|
203
|
+
const json = parseJson(raw);
|
|
204
|
+
if (json === undefined)
|
|
205
|
+
return emptyProfile();
|
|
206
|
+
return migrateProfile(json).profile;
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Write `profile` (with a refreshed `updatedAt`) atomically: serialize → write
|
|
210
|
+
* a unique temp file in the same directory → `fs.rename` over the target. The
|
|
211
|
+
* rename is atomic on the same filesystem, so a concurrent reader sees either
|
|
212
|
+
* the old or the new file, never a partial one.
|
|
213
|
+
*
|
|
214
|
+
* @returns The exact profile object that was persisted.
|
|
215
|
+
*/
|
|
216
|
+
const writeAtomic = async (dir, file, profile) => {
|
|
217
|
+
const toWrite = { ...profile, updatedAt: new Date().toISOString() };
|
|
218
|
+
// Validate before persisting so we never write a malformed document.
|
|
219
|
+
const validated = ProfileSchema.parse(toWrite);
|
|
220
|
+
const json = `${JSON.stringify(validated, null, 2)}\n`;
|
|
221
|
+
const tmp = path.join(dir, `.${PROFILE_FILENAME}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
222
|
+
try {
|
|
223
|
+
await fs.writeFile(tmp, json, { encoding: "utf8", mode: 0o600 });
|
|
224
|
+
await fs.rename(tmp, file);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
// Best-effort cleanup of the temp file if the rename never happened.
|
|
228
|
+
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
return validated;
|
|
232
|
+
};
|
|
233
|
+
/** Create an empty file if it does not already exist (no-op otherwise). */
|
|
234
|
+
const ensureExists = async (file) => {
|
|
235
|
+
// wx = create + fail if exists; swallow EEXIST so this is idempotent.
|
|
236
|
+
let handle;
|
|
237
|
+
try {
|
|
238
|
+
handle = await fs.open(file, "wx", 0o600);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
if (isExists(err))
|
|
242
|
+
return;
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
await handle?.close();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* Parse raw profile text into a JSON value, or `undefined` if it is not valid
|
|
251
|
+
* JSON. Schema/version validation is deferred to {@link migrateProfile} so the
|
|
252
|
+
* version policy (same/older/newer) is centralized in one place (T056).
|
|
253
|
+
*/
|
|
254
|
+
const parseJson = (raw) => {
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(raw);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
/** Type-narrowing helper: is this a Node `ENOENT` error? */
|
|
263
|
+
const isNotFound = (err) => hasCode(err, "ENOENT");
|
|
264
|
+
/** Type-narrowing helper: is this a Node `EEXIST` error? */
|
|
265
|
+
const isExists = (err) => hasCode(err, "EEXIST");
|
|
266
|
+
const hasCode = (err, code) => typeof err === "object" && err !== null && err.code === code;
|
|
267
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/profile/store.ts"],"names":[],"mappings":"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"}
|