@avytheone/efmesh 0.2.0 → 0.2.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/CHANGELOG.md +35 -0
- package/README.md +22 -0
- package/README.ru.md +23 -0
- package/SPEC.md +1 -1
- package/package.json +1 -1
- package/src/bin.ts +39 -2
- package/src/cli.ts +204 -124
- package/src/config.ts +15 -15
- package/src/core/audit.ts +15 -11
- package/src/core/errors.ts +37 -7
- package/src/core/graph.ts +6 -6
- package/src/core/interval.ts +18 -18
- package/src/core/model.ts +76 -75
- package/src/core/sql.ts +21 -21
- package/src/discovery.ts +16 -8
- package/src/efmesh.ts +26 -15
- package/src/engine/adapter.ts +29 -12
- package/src/engine/duckdb.ts +7 -7
- package/src/engine/postgres.ts +17 -17
- package/src/error-text.ts +35 -0
- package/src/index.ts +12 -11
- package/src/init.ts +100 -28
- package/src/plan/audit-run.ts +21 -13
- package/src/plan/categorize.ts +7 -7
- package/src/plan/contract.ts +23 -19
- package/src/plan/diff.ts +33 -29
- package/src/plan/executor.ts +207 -125
- package/src/plan/explain.ts +29 -29
- package/src/plan/fingerprint.ts +35 -31
- package/src/plan/graph-html.ts +7 -7
- package/src/plan/janitor.ts +30 -25
- package/src/plan/lineage.ts +20 -16
- package/src/plan/lock.ts +27 -10
- package/src/plan/metrics.ts +4 -4
- package/src/plan/naming.ts +23 -23
- package/src/plan/planner.ts +107 -90
- package/src/plan/run.ts +27 -19
- package/src/plan/schedule.ts +46 -41
- package/src/plan/status.ts +15 -14
- package/src/state/postgres.ts +19 -19
- package/src/state/sqlite.ts +27 -27
- package/src/state/store.ts +67 -55
- package/src/testing/index.ts +27 -26
package/src/plan/schedule.ts
CHANGED
|
@@ -4,49 +4,53 @@ import { fileURLToPath } from "node:url"
|
|
|
4
4
|
import { Data, Effect } from "effect"
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* `efmesh schedule` (#10):
|
|
8
|
-
* Bun.cron (>=1.3.11): crontab
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* `efmesh schedule` (#10): registers a `run` tick in the OS scheduler via
|
|
8
|
+
* Bun.cron (>=1.3.11): crontab on Linux, launchd on macOS, Task Scheduler
|
|
9
|
+
* on Windows. One title = one entry; re-registration overwrites in place
|
|
10
|
+
* (idempotent).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* Honest caveats (documented in README/SPEC): cron does not catch up on
|
|
13
|
+
* missed runs (systemd Persistent=true is stricter — hence --print-systemd),
|
|
14
|
+
* the OS level lives in the LOCAL timezone (TZ=UTC on top), on Linux a live
|
|
15
|
+
* cron daemon is required — the Arch family without cronie has none at all.
|
|
16
|
+
* Overlapping ticks are safe by construction: run holds the env lock, and
|
|
17
|
+
* "awaiting a human" = exit 2, not a failure.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
export class ScheduleError extends Data.TaggedError("ScheduleError")<{
|
|
21
21
|
readonly reason: string
|
|
22
|
-
}> {
|
|
22
|
+
}> {
|
|
23
|
+
override get message(): string {
|
|
24
|
+
return this.reason
|
|
25
|
+
}
|
|
26
|
+
}
|
|
23
27
|
|
|
24
|
-
/**
|
|
28
|
+
/** Everything the worker needs, as absolute paths (crontab has no cwd). */
|
|
25
29
|
export interface ScheduleTarget {
|
|
26
|
-
/**
|
|
30
|
+
/** Project directory (where the config lives) — the tick's cwd. */
|
|
27
31
|
readonly project: string
|
|
28
|
-
/**
|
|
32
|
+
/** Absolute path of the config. */
|
|
29
33
|
readonly config: string
|
|
30
34
|
readonly env: string
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
/**
|
|
37
|
+
/** Bun.cron title: only [A-Za-z0-9_-]; includes the project and environment. */
|
|
34
38
|
export const scheduleTitle = (target: Pick<ScheduleTarget, "project" | "env">): string =>
|
|
35
39
|
`efmesh-${basename(target.project)}-${target.env}`.replaceAll(/[^A-Za-z0-9_-]/g, "-")
|
|
36
40
|
|
|
37
|
-
/**
|
|
41
|
+
/** Where the worker is generated: next to the config, in the internal .efmesh/. */
|
|
38
42
|
export const workerPath = (target: ScheduleTarget): string =>
|
|
39
43
|
join(target.project, ".efmesh", `schedule-${target.env}.ts`)
|
|
40
44
|
|
|
41
|
-
/**
|
|
45
|
+
/** CLI binary of this same package — the worker calls it without guessing npm names. */
|
|
42
46
|
const binPath = (): string => fileURLToPath(new URL("../bin.ts", import.meta.url))
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
49
|
+
* Worker source for Bun.cron: the OS scheduler runs scheduled(), which runs
|
|
50
|
+
* an ordinary `efmesh run` — the same exit-code semantics (2 = "awaiting a
|
|
51
|
+
* human") and the same tick journal in the store (`efmesh status`).
|
|
48
52
|
*/
|
|
49
|
-
export const workerSource = (target: ScheduleTarget): string => `//
|
|
53
|
+
export const workerSource = (target: ScheduleTarget): string => `// generated by \`efmesh schedule\` — do not edit: re-registration overwrites it
|
|
50
54
|
export default {
|
|
51
55
|
async scheduled() {
|
|
52
56
|
const proc = Bun.spawn(
|
|
@@ -58,7 +62,7 @@ export default {
|
|
|
58
62
|
}
|
|
59
63
|
`
|
|
60
64
|
|
|
61
|
-
/**
|
|
65
|
+
/** cron nicknames → systemd OnCalendar; arbitrary expressions are not translated. */
|
|
62
66
|
export const cronToOnCalendar = (cron: string): string | undefined =>
|
|
63
67
|
(
|
|
64
68
|
{
|
|
@@ -73,8 +77,9 @@ export const cronToOnCalendar = (cron: string): string | undefined =>
|
|
|
73
77
|
)[cron.trim()]
|
|
74
78
|
|
|
75
79
|
/**
|
|
76
|
-
* systemd
|
|
77
|
-
*
|
|
80
|
+
* systemd fallback (--print-systemd): cron has no catch-up for missed runs
|
|
81
|
+
* and the Arch family has no daemon — a user timer with Persistent=true is
|
|
82
|
+
* more honest.
|
|
78
83
|
*/
|
|
79
84
|
export const systemdUnits = (
|
|
80
85
|
target: ScheduleTarget,
|
|
@@ -91,13 +96,13 @@ Description=efmesh run ${target.env} (${basename(target.project)})
|
|
|
91
96
|
Type=oneshot
|
|
92
97
|
WorkingDirectory=${target.project}
|
|
93
98
|
ExecStart=${process.execPath} ${binPath()} run ${target.env} --config ${target.config}
|
|
94
|
-
# exit 2 =
|
|
99
|
+
# exit 2 = "awaiting a human" (apply needed) — deliberately a unit failure: visible in alerts
|
|
95
100
|
`,
|
|
96
101
|
timer: `[Unit]
|
|
97
|
-
Description=efmesh run ${target.env} —
|
|
102
|
+
Description=efmesh run ${target.env} — timer
|
|
98
103
|
|
|
99
104
|
[Timer]
|
|
100
|
-
OnCalendar=${calendar ?? `hourly # TODO:
|
|
105
|
+
OnCalendar=${calendar ?? `hourly # TODO: translate "${cron}" into OnCalendar by hand`}
|
|
101
106
|
Persistent=true
|
|
102
107
|
|
|
103
108
|
[Install]
|
|
@@ -106,28 +111,28 @@ WantedBy=timers.target
|
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
|
|
109
|
-
/**
|
|
114
|
+
/** Validate the expression with the same parser that will execute it. */
|
|
110
115
|
export const validateCron = (cron: string): Effect.Effect<void, ScheduleError> =>
|
|
111
116
|
Effect.gen(function* () {
|
|
112
117
|
const next = yield* Effect.try({
|
|
113
118
|
try: () => Bun.cron.parse(cron),
|
|
114
|
-
catch: () => new ScheduleError({ reason:
|
|
119
|
+
catch: () => new ScheduleError({ reason: `cannot parse cron expression "${cron}"` }),
|
|
115
120
|
})
|
|
116
121
|
if (next === null) {
|
|
117
122
|
return yield* new ScheduleError({
|
|
118
|
-
reason: `cron
|
|
123
|
+
reason: `cron expression "${cron}" will never fire`,
|
|
119
124
|
})
|
|
120
125
|
}
|
|
121
126
|
})
|
|
122
127
|
|
|
123
|
-
/** Linux
|
|
128
|
+
/** Linux without a crontab binary (Arch family) — an honest error with a recipe. */
|
|
124
129
|
const requireCrontab = (): Effect.Effect<void, ScheduleError> =>
|
|
125
130
|
Effect.gen(function* () {
|
|
126
131
|
if (process.platform !== "linux" || Bun.which("crontab") !== null) return
|
|
127
132
|
return yield* new ScheduleError({
|
|
128
133
|
reason:
|
|
129
|
-
"
|
|
130
|
-
"
|
|
134
|
+
"this machine has no crontab (the Arch family ships no cron daemon): " +
|
|
135
|
+
"install cronie OR use a systemd timer — efmesh schedule <env> --print-systemd",
|
|
131
136
|
})
|
|
132
137
|
})
|
|
133
138
|
|
|
@@ -139,7 +144,7 @@ export const registerSchedule = (
|
|
|
139
144
|
yield* validateCron(cron)
|
|
140
145
|
yield* requireCrontab()
|
|
141
146
|
if (!existsSync(target.config)) {
|
|
142
|
-
return yield* new ScheduleError({ reason:
|
|
147
|
+
return yield* new ScheduleError({ reason: `config not found: ${target.config}` })
|
|
143
148
|
}
|
|
144
149
|
const worker = workerPath(target)
|
|
145
150
|
yield* Effect.try({
|
|
@@ -148,13 +153,13 @@ export const registerSchedule = (
|
|
|
148
153
|
writeFileSync(worker, workerSource(target))
|
|
149
154
|
},
|
|
150
155
|
catch: (cause) =>
|
|
151
|
-
new ScheduleError({ reason:
|
|
156
|
+
new ScheduleError({ reason: `worker was not written: ${String(cause)}` }),
|
|
152
157
|
})
|
|
153
158
|
const title = scheduleTitle(target)
|
|
154
159
|
yield* Effect.tryPromise({
|
|
155
160
|
try: () => Bun.cron(worker, cron, title),
|
|
156
161
|
catch: (cause) =>
|
|
157
|
-
new ScheduleError({ reason: `Bun.cron
|
|
162
|
+
new ScheduleError({ reason: `Bun.cron did not register: ${String(cause)}` }),
|
|
158
163
|
})
|
|
159
164
|
return { title, worker }
|
|
160
165
|
})
|
|
@@ -168,14 +173,14 @@ export const removeSchedule = (
|
|
|
168
173
|
try: () => Bun.cron.remove(title),
|
|
169
174
|
catch: (cause) => new ScheduleError({ reason: `Bun.cron.remove: ${String(cause)}` }),
|
|
170
175
|
})
|
|
171
|
-
//
|
|
176
|
+
// the worker is no longer executed by anyone — clean it up silently
|
|
172
177
|
yield* Effect.sync(() => rmSync(workerPath(target), { force: true }))
|
|
173
178
|
return { title }
|
|
174
179
|
})
|
|
175
180
|
|
|
176
181
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
182
|
+
* List of efmesh registrations — by Bun.cron's platform traces: `# bun-cron:
|
|
183
|
+
* <title>` markers in crontab (Linux) / launchd plists (macOS).
|
|
179
184
|
*/
|
|
180
185
|
export const listSchedules = (): Effect.Effect<ReadonlyArray<string>, ScheduleError> =>
|
|
181
186
|
Effect.gen(function* () {
|
|
@@ -185,7 +190,7 @@ export const listSchedules = (): Effect.Effect<ReadonlyArray<string>, ScheduleEr
|
|
|
185
190
|
try: async () => {
|
|
186
191
|
const proc = Bun.spawn(["crontab", "-l"], { stdout: "pipe", stderr: "ignore" })
|
|
187
192
|
const text = await proc.stdout.text()
|
|
188
|
-
await proc.exited //
|
|
193
|
+
await proc.exited // an empty crontab exits with 1 — that is not an error
|
|
189
194
|
return text
|
|
190
195
|
},
|
|
191
196
|
catch: (cause) => new ScheduleError({ reason: `crontab -l: ${String(cause)}` }),
|
|
@@ -207,6 +212,6 @@ export const listSchedules = (): Effect.Effect<ReadonlyArray<string>, ScheduleEr
|
|
|
207
212
|
return out.split("\n").filter((line) => line.includes("bun.cron.efmesh-"))
|
|
208
213
|
}
|
|
209
214
|
return yield* new ScheduleError({
|
|
210
|
-
reason:
|
|
215
|
+
reason: `list on ${process.platform} via the OS tools (Task Scheduler: schtasks /query)`,
|
|
211
216
|
})
|
|
212
217
|
})
|
package/src/plan/status.ts
CHANGED
|
@@ -6,39 +6,40 @@ import { STATE_VERSION, StateStore } from "../state/store.ts"
|
|
|
6
6
|
import type { PlanRecord, RunRecord, StateError } from "../state/store.ts"
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* `efmesh status <env>` (issue #1):
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* `efmesh status <env>` (issue #1): one command for the question «what is
|
|
10
|
+
* even going on» — for the nightly-cron operator and for the author who
|
|
11
|
+
* forgot the commands. Read-only: if the store opened → its schema version
|
|
12
|
+
* already matched.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
export interface ModelLag {
|
|
15
16
|
readonly model: string
|
|
16
|
-
/**
|
|
17
|
+
/** End of the last done interval (ISO); null — nothing computed yet. */
|
|
17
18
|
readonly doneUpTo: string | null
|
|
18
|
-
/**
|
|
19
|
+
/** How many intervals are missing up to «now». 0 — caught up. */
|
|
19
20
|
readonly missing: number
|
|
20
|
-
/**
|
|
21
|
+
/** Intervals marked failed — a stuck backfill is visible at once. */
|
|
21
22
|
readonly failed: number
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface StatusReport {
|
|
25
26
|
readonly env: string
|
|
26
27
|
readonly storeVersion: number
|
|
27
|
-
/**
|
|
28
|
+
/** Rows in the environment; 0 — the environment does not exist (never applied). */
|
|
28
29
|
readonly models: number
|
|
29
|
-
/**
|
|
30
|
+
/** Last promotion (ISO); null — no environment. */
|
|
30
31
|
readonly promotedAt: string | null
|
|
31
32
|
readonly lastPlan: PlanRecord | null
|
|
32
|
-
/**
|
|
33
|
+
/** Latest run ticks, freshest first. */
|
|
33
34
|
readonly ticks: ReadonlyArray<RunRecord>
|
|
34
|
-
/**
|
|
35
|
+
/** Lag of the environment's incremental models. */
|
|
35
36
|
readonly lag: ReadonlyArray<ModelLag>
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export interface StatusOptions {
|
|
39
|
-
/**
|
|
40
|
+
/** «Now» for computing lag; by default — Clock. Injected for tests. */
|
|
40
41
|
readonly now?: number
|
|
41
|
-
/**
|
|
42
|
+
/** How many latest ticks to show; by default 5. */
|
|
42
43
|
readonly ticks?: number
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -62,8 +63,8 @@ export const environmentStatus = (
|
|
|
62
63
|
const lastPlan = plans.at(-1) ?? null
|
|
63
64
|
const ticks = yield* store.listRuns(env, options?.ticks ?? 5)
|
|
64
65
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
66
|
+
// lag — by the environment's POINTERS (what is actually served to
|
|
67
|
+
// consumers), not by the project's local fingerprints
|
|
67
68
|
const lag: Array<ModelLag> = []
|
|
68
69
|
for (const row of rows) {
|
|
69
70
|
const model = graph.models.get(row.name)
|
package/src/state/postgres.ts
CHANGED
|
@@ -12,11 +12,11 @@ import type {
|
|
|
12
12
|
import { STATE_VERSION, StateError, StateSchemaError, StateStore } from "./store.ts"
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* State store
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* State store on Postgres (SPEC §6, F3) — for team/production work: state
|
|
16
|
+
* survives concurrent runs from different processes and machines.
|
|
17
|
+
* Schema `efmesh_state`, semantics identical to the bun:sqlite implementation;
|
|
18
|
+
* timestamps are ISO UTC text (lexicographically sortable), same as in
|
|
19
|
+
* SQLite: the store's contents are portable between backends.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const SCHEMA = `
|
|
@@ -75,9 +75,9 @@ CREATE TABLE IF NOT EXISTS efmesh_state.locks (
|
|
|
75
75
|
`
|
|
76
76
|
|
|
77
77
|
export interface PostgresStateOptions {
|
|
78
|
-
/** postgres://…
|
|
78
|
+
/** postgres://… or a unix socket via ?host=/path. */
|
|
79
79
|
readonly url: string
|
|
80
|
-
/**
|
|
80
|
+
/** Pool size; a couple of connections are enough for state. */
|
|
81
81
|
readonly max?: number
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -88,7 +88,7 @@ const regclass = async (pool: SQL, table: string): Promise<boolean> => {
|
|
|
88
88
|
return rows[0]?.r != null
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/** 0 —
|
|
91
|
+
/** 0 — a store with no meta table (created before versioning existed, F0–F3). */
|
|
92
92
|
const readVersion = async (pool: SQL): Promise<number> => {
|
|
93
93
|
if (!(await regclass(pool, "meta"))) return 0
|
|
94
94
|
const rows = (await pool.unsafe(
|
|
@@ -97,7 +97,7 @@ const readVersion = async (pool: SQL): Promise<number> => {
|
|
|
97
97
|
return rows[0]?.version ?? 0
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/**
|
|
100
|
+
/** Catches the schema up to STATE_VERSION (see the sqlite implementation — same semantics). */
|
|
101
101
|
const applyMigrations = async (pool: SQL): Promise<void> => {
|
|
102
102
|
await pool.unsafe(SCHEMA)
|
|
103
103
|
await pool.unsafe(`
|
|
@@ -114,7 +114,7 @@ const applyMigrations = async (pool: SQL): Promise<void> => {
|
|
|
114
114
|
})
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/** `efmesh migrate`:
|
|
117
|
+
/** `efmesh migrate`: an explicit schema upgrade of an existing store. */
|
|
118
118
|
export const migratePostgresState = (
|
|
119
119
|
options: PostgresStateOptions,
|
|
120
120
|
): Effect.Effect<MigrationReport, StateError> =>
|
|
@@ -153,8 +153,8 @@ export const PostgresStateLive = (
|
|
|
153
153
|
}),
|
|
154
154
|
(pool) => Effect.promise(() => pool.end()).pipe(Effect.ignore),
|
|
155
155
|
)
|
|
156
|
-
//
|
|
157
|
-
//
|
|
156
|
+
// a fresh store bootstraps at the current version; an existing store
|
|
157
|
+
// with an older schema requires an explicit `efmesh migrate` (SPEC §6)
|
|
158
158
|
const fresh = yield* Effect.tryPromise({
|
|
159
159
|
try: async () => !(await regclass(sql, "snapshots")),
|
|
160
160
|
catch: (cause) => new StateError({ operation: "open", cause }),
|
|
@@ -190,7 +190,7 @@ export const PostgresStateLive = (
|
|
|
190
190
|
Effect.flatMap((now) =>
|
|
191
191
|
attempt("upsertSnapshot", async () => {
|
|
192
192
|
await sql.unsafe(
|
|
193
|
-
//
|
|
193
|
+
// reviving clears orphan status and refreshes created_at — same as sqlite (race, F6)
|
|
194
194
|
`INSERT INTO efmesh_state.snapshots
|
|
195
195
|
(name, fingerprint, rendered_sql, canonical_ast, physical_fp, kind, fingerprint_version, created_at)
|
|
196
196
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
@@ -284,8 +284,8 @@ export const PostgresStateLive = (
|
|
|
284
284
|
Effect.flatMap((now) =>
|
|
285
285
|
attempt("promote", () =>
|
|
286
286
|
sql.begin(async (tx) => {
|
|
287
|
-
//
|
|
288
|
-
// janitor
|
|
287
|
+
// snapshot liveness — in the same transaction (race, F6):
|
|
288
|
+
// janitor removed the version → loud error, the view is left alone
|
|
289
289
|
for (const entry of entries) {
|
|
290
290
|
if (entry.requireSnapshot !== true) continue
|
|
291
291
|
const alive = (await tx.unsafe(
|
|
@@ -294,7 +294,7 @@ export const PostgresStateLive = (
|
|
|
294
294
|
)) as ReadonlyArray<unknown>
|
|
295
295
|
if (alive.length === 0) {
|
|
296
296
|
throw new Error(
|
|
297
|
-
|
|
297
|
+
`promotion "${env}": snapshot ${entry.name}@${entry.fingerprint.slice(0, 8)} vanished from the store (removed by janitor?) — retry apply`,
|
|
298
298
|
)
|
|
299
299
|
}
|
|
300
300
|
}
|
|
@@ -306,7 +306,7 @@ export const PostgresStateLive = (
|
|
|
306
306
|
[env, entry.name, entry.fingerprint, now],
|
|
307
307
|
)
|
|
308
308
|
}
|
|
309
|
-
//
|
|
309
|
+
// orphan bookkeeping — same as the sqlite implementation (SPEC §5.4)
|
|
310
310
|
await tx.unsafe(
|
|
311
311
|
`UPDATE efmesh_state.snapshots SET orphaned_at = $1
|
|
312
312
|
WHERE orphaned_at IS NULL
|
|
@@ -387,8 +387,8 @@ export const PostgresStateLive = (
|
|
|
387
387
|
sql.begin(async (tx) => {
|
|
388
388
|
const now = new Date(nowMs).toISOString()
|
|
389
389
|
const expires = new Date(nowMs + ttlMs).toISOString()
|
|
390
|
-
//
|
|
391
|
-
// <= —
|
|
390
|
+
// a stale lock from a crashed process is reclaimed;
|
|
391
|
+
// <= — a lock that expires at instant T is free as of T
|
|
392
392
|
await tx.unsafe(
|
|
393
393
|
`DELETE FROM efmesh_state.locks WHERE name = $1 AND expires_at <= $2`,
|
|
394
394
|
[name, now],
|
package/src/state/sqlite.ts
CHANGED
|
@@ -67,7 +67,7 @@ CREATE TABLE IF NOT EXISTS locks (
|
|
|
67
67
|
`
|
|
68
68
|
|
|
69
69
|
export interface SqliteStateOptions {
|
|
70
|
-
/**
|
|
70
|
+
/** Path to the state file; defaults to in-memory (tests). */
|
|
71
71
|
readonly path?: string
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -78,7 +78,7 @@ const tableExists = (db: Database, name: string): boolean =>
|
|
|
78
78
|
.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1`)
|
|
79
79
|
.get(name) !== null
|
|
80
80
|
|
|
81
|
-
/** 0 —
|
|
81
|
+
/** 0 — a store with no meta table (created before versioning existed, F0–F3). */
|
|
82
82
|
const readVersion = (db: Database): number => {
|
|
83
83
|
if (!tableExists(db, "meta")) return 0
|
|
84
84
|
const row = db.query(`SELECT version FROM meta`).get() as { version: number } | null
|
|
@@ -86,11 +86,11 @@ const readVersion = (db: Database): number => {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* orphaned_at/physical_fp — F3, applied_by — F5)
|
|
93
|
-
*
|
|
89
|
+
* Catches the schema up to STATE_VERSION. Version 1 = base layout F4,
|
|
90
|
+
* version 2 = applied_by in the plan journal (F5); the ALTERs below pick up
|
|
91
|
+
* stores created before the corresponding columns existed (canonical_ast —
|
|
92
|
+
* F2, orphaned_at/physical_fp — F3, applied_by — F5) — on a fresh store
|
|
93
|
+
* they are a no-op via try/catch. Future versions get new entries right here.
|
|
94
94
|
*/
|
|
95
95
|
const applyMigrations = (db: Database): void => {
|
|
96
96
|
db.exec(SCHEMA)
|
|
@@ -104,7 +104,7 @@ const applyMigrations = (db: Database): void => {
|
|
|
104
104
|
try {
|
|
105
105
|
db.exec(alter)
|
|
106
106
|
} catch {
|
|
107
|
-
//
|
|
107
|
+
// column already exists
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
db.exec(`CREATE TABLE IF NOT EXISTS meta (version INTEGER NOT NULL)`)
|
|
@@ -113,9 +113,9 @@ const applyMigrations = (db: Database): void => {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
* `efmesh migrate`:
|
|
117
|
-
*
|
|
118
|
-
*
|
|
116
|
+
* `efmesh migrate`: an explicit schema upgrade of an existing store.
|
|
117
|
+
* Before the upgrade, the file is copied to `<path>.backup-v<from>` —
|
|
118
|
+
* otherwise rolling back to an older efmesh version would be a one-way trip (F6).
|
|
119
119
|
*/
|
|
120
120
|
export const migrateSqliteState = (
|
|
121
121
|
options?: SqliteStateOptions,
|
|
@@ -157,9 +157,9 @@ export const SqliteStateLive = (
|
|
|
157
157
|
}),
|
|
158
158
|
(db) => Effect.sync(() => db.close()),
|
|
159
159
|
)
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
160
|
+
// a fresh store bootstraps at the current version; an existing store
|
|
161
|
+
// with an older schema requires an explicit `efmesh migrate` — silently
|
|
162
|
+
// rewriting someone else's data on open is not allowed (SPEC §6)
|
|
163
163
|
const fresh = yield* Effect.try({
|
|
164
164
|
try: () => !tableExists(db, "snapshots"),
|
|
165
165
|
catch: (cause) => new StateError({ operation: "open", cause }),
|
|
@@ -187,10 +187,10 @@ export const SqliteStateLive = (
|
|
|
187
187
|
isoNow.pipe(
|
|
188
188
|
Effect.flatMap((now) =>
|
|
189
189
|
attempt("upsertSnapshot", () => {
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// orphaned_at
|
|
190
|
+
// reviving a version (a repeated apply of an old fingerprint)
|
|
191
|
+
// clears orphan status AND refreshes created_at IMMEDIATELY: between
|
|
192
|
+
// the upsert and promotion, the janitor won't judge it doomed by
|
|
193
|
+
// either orphaned_at or a stale created_at (race, F6)
|
|
194
194
|
db.query(
|
|
195
195
|
`INSERT INTO snapshots (name, fingerprint, rendered_sql, canonical_ast, physical_fp, kind, fingerprint_version, created_at)
|
|
196
196
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
@@ -294,16 +294,16 @@ export const SqliteStateLive = (
|
|
|
294
294
|
Effect.flatMap((now) =>
|
|
295
295
|
attempt("promote", () => {
|
|
296
296
|
const replace = db.transaction(() => {
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
297
|
+
// snapshot liveness — in the same transaction: if the janitor
|
|
298
|
+
// already removed the version, promotion fails loudly, and the
|
|
299
|
+
// view never switches to demolished physics (race, F6)
|
|
300
300
|
const alive = db.query(
|
|
301
301
|
`SELECT 1 FROM snapshots WHERE name = ?1 AND fingerprint = ?2`,
|
|
302
302
|
)
|
|
303
303
|
for (const entry of entries) {
|
|
304
304
|
if (entry.requireSnapshot === true && alive.get(entry.name, entry.fingerprint) === null) {
|
|
305
305
|
throw new Error(
|
|
306
|
-
|
|
306
|
+
`promotion "${env}": snapshot ${entry.name}@${entry.fingerprint.slice(0, 8)} vanished from the store (removed by janitor?) — retry apply`,
|
|
307
307
|
)
|
|
308
308
|
}
|
|
309
309
|
}
|
|
@@ -315,9 +315,9 @@ export const SqliteStateLive = (
|
|
|
315
315
|
for (const entry of entries) {
|
|
316
316
|
insert.run(env, entry.name, entry.fingerprint, now)
|
|
317
317
|
}
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
// (
|
|
318
|
+
// referencing changes only here — so orphan bookkeeping happens
|
|
319
|
+
// right here too: those losing their last reference get marked,
|
|
320
|
+
// those returning (rollback to an old version) lose the mark (SPEC §5.4)
|
|
321
321
|
db.query(
|
|
322
322
|
`UPDATE snapshots SET orphaned_at = ?1
|
|
323
323
|
WHERE orphaned_at IS NULL
|
|
@@ -396,8 +396,8 @@ export const SqliteStateLive = (
|
|
|
396
396
|
const now = new Date(nowMs).toISOString()
|
|
397
397
|
const expires = new Date(nowMs + ttlMs).toISOString()
|
|
398
398
|
const acquire = db.transaction(() => {
|
|
399
|
-
//
|
|
400
|
-
// <= —
|
|
399
|
+
// a stale lock from a crashed process is reclaimed;
|
|
400
|
+
// <= — a lock that expires at instant T is free as of T (ttl=0 in the same ms)
|
|
401
401
|
db.query(`DELETE FROM locks WHERE name = ?1 AND expires_at <= ?2`).run(name, now)
|
|
402
402
|
const result = db
|
|
403
403
|
.query(
|