@nebutra/time-machine 0.1.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/src/index.ts ADDED
@@ -0,0 +1,255 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { appendCapabilityDebug, readCapabilityDebug } from "@nebutra/capability-kit/debug";
4
+ import { ContentStore } from "@nebutra/content-store";
5
+ import { CapabilityError } from "@nebutra/errors";
6
+ import { EventLog, type EventRecord, type RollbackPlan } from "@nebutra/event-log";
7
+ import { assetId } from "@nebutra/generation-context";
8
+
9
+ export type TimelineNodeKind = "milestone" | "decision" | "branch" | "play_completed";
10
+
11
+ export interface TimelineNode {
12
+ readonly id: string;
13
+ readonly eventId: string;
14
+ readonly kind: TimelineNodeKind;
15
+ readonly timestamp: string;
16
+ readonly oneLineSummary: string;
17
+ readonly branchId: string;
18
+ readonly parentNode?: string;
19
+ readonly affectedAssets: readonly string[];
20
+ readonly preview: {
21
+ readonly kind: "event-summary";
22
+ readonly text: string;
23
+ };
24
+ }
25
+
26
+ export interface TimelineView {
27
+ readonly tenantId: string;
28
+ readonly truthSource: "event-log";
29
+ readonly nodes: readonly TimelineNode[];
30
+ }
31
+
32
+ export interface TimelineBranch {
33
+ readonly branchId: string;
34
+ readonly name: string;
35
+ readonly fromEventId: string;
36
+ readonly createdAt: string;
37
+ }
38
+
39
+ export interface TimelineAnnotation {
40
+ readonly eventId: string;
41
+ readonly starred: true;
42
+ readonly label: string;
43
+ readonly path: string;
44
+ }
45
+
46
+ export interface TimelineComparison {
47
+ readonly leftEventId: string;
48
+ readonly rightEventId: string;
49
+ readonly changedAssets: readonly string[];
50
+ readonly narrative: string;
51
+ }
52
+
53
+ export interface TimeMachineDoctorReport {
54
+ readonly capability: "time-machine";
55
+ readonly ok: boolean;
56
+ readonly checkedAt: string;
57
+ readonly truthSource: "event-log";
58
+ readonly features: readonly string[];
59
+ readonly suggestion?: string;
60
+ }
61
+
62
+ export interface TimeMachineOptions {
63
+ readonly tenantId?: string;
64
+ readonly root?: string;
65
+ readonly debugRoot?: string;
66
+ readonly contentStore?: ContentStore;
67
+ readonly eventLog?: EventLog;
68
+ }
69
+
70
+ function requireTenant(value: string | undefined): string {
71
+ if (!value?.trim()) {
72
+ throw new CapabilityError("time-machine", "Time Machine requires tenant context", {
73
+ suggestion: "Pass tenantId when opening TimeMachine so timeline reads stay tenant-scoped.",
74
+ statusCode: 400,
75
+ });
76
+ }
77
+ return value;
78
+ }
79
+
80
+ function nodeKind(event: EventRecord): TimelineNodeKind {
81
+ if (event.traceId.includes("branch")) return "branch";
82
+ if (event.traceId.includes("decision")) return "decision";
83
+ if (event.traceId.includes("play")) return "play_completed";
84
+ return "milestone";
85
+ }
86
+
87
+ function toNode(event: EventRecord): TimelineNode {
88
+ return {
89
+ id: `node_${event.id}`,
90
+ eventId: event.id,
91
+ kind: nodeKind(event),
92
+ timestamp: event.at,
93
+ oneLineSummary: event.summary,
94
+ branchId: "main",
95
+ ...(event.parent ? { parentNode: `node_${event.parent}` } : {}),
96
+ affectedAssets: event.affected,
97
+ preview: { kind: "event-summary", text: event.summary },
98
+ };
99
+ }
100
+
101
+ function unique(values: readonly string[]): string[] {
102
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
103
+ }
104
+
105
+ export class TimeMachine {
106
+ readonly #tenantId: string;
107
+ readonly #debugRoot: string;
108
+ readonly #contentStore: ContentStore;
109
+ readonly #eventLog: EventLog;
110
+
111
+ private constructor(
112
+ options: Required<Pick<TimeMachineOptions, "tenantId" | "contentStore" | "eventLog">> &
113
+ Pick<TimeMachineOptions, "debugRoot">,
114
+ ) {
115
+ this.#tenantId = requireTenant(options.tenantId);
116
+ this.#debugRoot = options.debugRoot ?? process.cwd();
117
+ this.#contentStore = options.contentStore;
118
+ this.#eventLog = options.eventLog;
119
+ }
120
+
121
+ static async open(
122
+ root = ".nebutra/time-machine",
123
+ options: Omit<TimeMachineOptions, "root" | "contentStore" | "eventLog"> = {},
124
+ ): Promise<TimeMachine> {
125
+ const tenantId = options.tenantId ?? "local";
126
+ await mkdir(root, { recursive: true });
127
+ const contentStore = await ContentStore.open(join(root, "content"), { tenantId });
128
+ const eventLog = await EventLog.open(join(root, "event-log"), { tenantId });
129
+ return new TimeMachine({ ...options, tenantId, contentStore, eventLog });
130
+ }
131
+
132
+ static async fromStores(
133
+ options: Required<Pick<TimeMachineOptions, "tenantId" | "contentStore" | "eventLog">> &
134
+ Pick<TimeMachineOptions, "root" | "debugRoot">,
135
+ ): Promise<TimeMachine> {
136
+ if (options.root) await mkdir(options.root, { recursive: true });
137
+ return new TimeMachine({
138
+ tenantId: options.tenantId,
139
+ contentStore: options.contentStore,
140
+ eventLog: options.eventLog,
141
+ ...((options.debugRoot ?? options.root) !== undefined
142
+ ? { debugRoot: options.debugRoot ?? options.root }
143
+ : {}),
144
+ });
145
+ }
146
+
147
+ async timelineView(): Promise<TimelineView> {
148
+ const events = await this.#eventLog.timeline();
149
+ const nodes = events.map(toNode);
150
+ await this.#debug({ type: "timeline_view", tenantId: this.#tenantId, nodes: nodes.length });
151
+ return { tenantId: this.#tenantId, truthSource: "event-log", nodes };
152
+ }
153
+
154
+ async branchFrom(eventId: string, name: string): Promise<TimelineBranch> {
155
+ const branch = await this.#eventLog.branchFrom(eventId, name);
156
+ const result = {
157
+ branchId: assetId("branch", `${eventId}_${name}`),
158
+ name: branch.name,
159
+ fromEventId: branch.from,
160
+ createdAt: branch.at,
161
+ };
162
+ await this.#debug({ type: "branch", tenantId: this.#tenantId, eventId, name });
163
+ return result;
164
+ }
165
+
166
+ async star(eventId: string, label = "starred"): Promise<TimelineAnnotation> {
167
+ await this.#requireEvent(eventId);
168
+ const annotation = {
169
+ eventId,
170
+ starred: true,
171
+ label,
172
+ createdAt: new Date().toISOString(),
173
+ truthSource: "event-log",
174
+ };
175
+ const path = `time-machine/annotations/${eventId}.json`;
176
+ await this.#contentStore.write(path, `${JSON.stringify(annotation, null, 2)}\n`);
177
+ await this.#eventLog.commit({
178
+ traceId: assetId("time_machine_annotation", eventId),
179
+ kind: "content_write",
180
+ affected: [path],
181
+ parent: eventId,
182
+ snapshot: { [path]: `${JSON.stringify(annotation, null, 2)}\n` },
183
+ });
184
+ await this.#debug({ type: "star", tenantId: this.#tenantId, eventId, label });
185
+ return { eventId, starred: true, label, path };
186
+ }
187
+
188
+ async compare(leftEventId: string, rightEventId: string): Promise<TimelineComparison> {
189
+ const left = await this.#requireEvent(leftEventId);
190
+ const right = await this.#requireEvent(rightEventId);
191
+ const changedAssets = unique([...left.affected, ...right.affected]);
192
+ const narrative = `Compared ${leftEventId} to ${rightEventId}: ${changedAssets.join(
193
+ ", ",
194
+ )} changed.`;
195
+ await this.#debug({
196
+ type: "compare",
197
+ tenantId: this.#tenantId,
198
+ leftEventId,
199
+ rightEventId,
200
+ changedAssets,
201
+ });
202
+ return { leftEventId, rightEventId, changedAssets, narrative };
203
+ }
204
+
205
+ async rollbackDryRun(eventId: string): Promise<RollbackPlan> {
206
+ const plan = await this.#eventLog.rollbackTo(eventId);
207
+ await this.#debug({
208
+ type: "rollback_dry_run",
209
+ tenantId: this.#tenantId,
210
+ eventId,
211
+ affected: plan.affected,
212
+ });
213
+ return plan;
214
+ }
215
+
216
+ async doctor(): Promise<TimeMachineDoctorReport> {
217
+ const events = await this.#eventLog.timeline();
218
+ const report: TimeMachineDoctorReport = {
219
+ capability: "time-machine",
220
+ ok: true,
221
+ checkedAt: new Date().toISOString(),
222
+ truthSource: "event-log",
223
+ features: ["timeline", "branch", "compare", "rollback-dry-run", "annotation"],
224
+ ...(events.length === 0
225
+ ? { suggestion: "Commit at least one event to Chronos before opening the star-map view." }
226
+ : {}),
227
+ };
228
+ await this.#debug({ type: "doctor", tenantId: this.#tenantId, events: events.length });
229
+ return report;
230
+ }
231
+
232
+ async close(): Promise<void> {
233
+ await this.#contentStore.close();
234
+ }
235
+
236
+ async #requireEvent(eventId: string): Promise<EventRecord> {
237
+ const event = (await this.#eventLog.timeline()).find((record) => record.id === eventId);
238
+ if (!event) {
239
+ throw new CapabilityError("time-machine", "Timeline event not found", {
240
+ suggestion: "Call timelineView and choose an event id from the current tenant.",
241
+ metadata: { eventId },
242
+ statusCode: 404,
243
+ });
244
+ }
245
+ return event;
246
+ }
247
+
248
+ async #debug(entry: Record<string, unknown>): Promise<void> {
249
+ await appendCapabilityDebug("time-machine", entry, { root: this.#debugRoot });
250
+ }
251
+ }
252
+
253
+ export async function readTimeMachineDebug(root = process.cwd(), limit = 20): Promise<unknown[]> {
254
+ return readCapabilityDebug("time-machine", { root, limit });
255
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "types": ["node", "vitest/globals"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ clean: true,
5
+ dts: true,
6
+ entry: ["src/index.ts", "src/cli.ts"],
7
+ format: ["esm"],
8
+ sourcemap: true,
9
+ });