@prettier-ai/dsh-workspace 0.1.2-alpha.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.
@@ -0,0 +1,114 @@
1
+ import "node:crypto";
2
+ import "node:fs/promises";
3
+ import "node:path";
4
+ import { Service } from "@prettier-ai/cordis";
5
+ import { z } from "zod";
6
+ import { SessionId } from "@prettier-ai/dsh-session";
7
+ import { defineDomain, domainTable } from "@prettier-ai/dsh-storage-domain";
8
+ //#endregion
9
+ //#region src/spec.ts
10
+ /**
11
+ * The workspace domain declaration: record schema and the `defineDomain` spec
12
+ * the registry opens. The zod schema validates the shipped format at the
13
+ * durability boundary and is the direct source of a future RPC wire projection.
14
+ * @module @prettier-ai/dsh-workspace/src/spec
15
+ */
16
+ /** Workspace id schema at the durable boundary; branding has no runtime representation. */
17
+ const workspaceId = z.string().transform((value) => value);
18
+ /**
19
+ * Durable shape of one workspace record. `path` is the `fs.realpath` canon
20
+ * stamped at create; `sessionIds` is the ordered ownership account (array
21
+ * order is display order); timestamps are ISO-8601 strings.
22
+ */
23
+ const workspaceRecord = z.object({
24
+ path: z.string(),
25
+ title: z.string(),
26
+ sessionIds: z.array(z.string().transform(SessionId)),
27
+ createdAt: z.string(),
28
+ updatedAt: z.string()
29
+ });
30
+ /**
31
+ * Recoverable two-write mutation marker. The marker is persisted before the
32
+ * record/order pair can diverge, so startup can distinguish an interrupted
33
+ * registry operation from unexplained medium corruption.
34
+ */
35
+ const workspacePendingMutation = z.discriminatedUnion("operation", [z.object({
36
+ operation: z.literal("create"),
37
+ workspaceId
38
+ }), z.object({
39
+ operation: z.literal("delete"),
40
+ workspaceId
41
+ })]);
42
+ defineDomain({
43
+ name: "workspace",
44
+ version: 2,
45
+ global: {
46
+ schema: z.object({
47
+ initialized: z.boolean(),
48
+ workspaceIds: z.array(workspaceId),
49
+ archivedSessionIds: z.array(z.string().transform(SessionId)).default([]),
50
+ pendingMutation: workspacePendingMutation.optional()
51
+ }),
52
+ initial: {
53
+ initialized: false,
54
+ workspaceIds: [],
55
+ archivedSessionIds: []
56
+ }
57
+ },
58
+ tables: { workspaces: domainTable(workspaceRecord) }
59
+ });
60
+ //#endregion
61
+ //#region src/index.ts
62
+ /**
63
+ * Workspace entity registry (`ctx.workspaceRegistry`): durable workspace records,
64
+ * stable registry order, and header-validated session membership over the
65
+ * domain data form.
66
+ * @module @prettier-ai/dsh-workspace
67
+ */
68
+ /**
69
+ * Brand a string as a {@link WorkspaceId}.
70
+ * @param id - Raw workspace id string.
71
+ * @returns the same string, branded at compile time.
72
+ */
73
+ function WorkspaceId(id) {
74
+ return id;
75
+ }
76
+ Service.init;
77
+ //#endregion
78
+ //#region lib/types/invariant.js
79
+ /**
80
+ * Package-owned invariant companion for `@prettier-ai/dsh-workspace`.
81
+ * @module @prettier-ai/dsh-workspace/invariant
82
+ */
83
+ const PACKAGE_NAME = "@prettier-ai/dsh-workspace";
84
+ /** Cordis companion plugin name. */
85
+ const name = "workspace-invariant";
86
+ /** Service required before the companion can reserve package ownership. */
87
+ const inject = ["invariants"];
88
+ /**
89
+ * Owned relationship: the registry's entity cache mirrors the workspace
90
+ * domain's durable table. Every `domain/changed` for the `workspaces` table
91
+ * must name a record the cache already holds an entity for (the registry
92
+ * caches before the durable put and mutates only through cached entities).
93
+ * A delete is valid only after the registry has removed the entity from its
94
+ * cache, whether for create rollback or an explicit registration deletion;
95
+ * deleting while the cache still publishes the entity proves a bypass.
96
+ */
97
+ const install = Object.assign((ctx, fail) => {
98
+ ctx.on("domain/changed", (change) => {
99
+ if (change.domain !== "workspace" || change.table !== "workspaces") return;
100
+ if (change.operation === "deleted") {
101
+ if (ctx.workspaceRegistry.get(WorkspaceId(change.key)) !== void 0) fail(`workspace record '${change.key}' was deleted while the registry cache still publishes it — some write path bypassed ctx.workspaceRegistry`);
102
+ return;
103
+ }
104
+ if (ctx.workspaceRegistry.get(WorkspaceId(change.key)) === void 0) fail(`workspace record '${change.key}' landed durably but the registry cache holds no entity for it — the cache and the domain table have diverged`);
105
+ });
106
+ }, { inject: ["workspaceRegistry"] });
107
+ /**
108
+ * Register this package's invariant companion.
109
+ * @param ctx - Cordis context carrying the invariant service.
110
+ * @returns the installed registration's disposer after setup succeeds.
111
+ */
112
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
113
+ //#endregion
114
+ export { apply, inject, name };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Package-private workspace entity: the single {@link Workspace}
3
+ * implementation. Holds a record snapshot that is swapped in place after each
4
+ * durable mutation; every write funnels through the private `mutate` so
5
+ * `updatedAt` stamping and invalid-account pruning happen exactly once.
6
+ * Not re-exported from the package entrypoint — consumers see only the
7
+ * `Workspace` interface.
8
+ * @module @prettier-ai/dsh-workspace/src/entity
9
+ */
10
+ import type { SessionHeader, SessionId } from '@prettier-ai/dsh-session';
11
+ import type { KvTable } from '@prettier-ai/dsh-storage-domain';
12
+ import type { WorkspaceRecord } from './spec.ts';
13
+ import type { Workspace, WorkspaceId } from './types.ts';
14
+ /** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */
15
+ export declare class WorkspaceMoveInvalidError extends Error {
16
+ /**
17
+ * @param message - Which id was unaccounted and where.
18
+ */
19
+ constructor(message: string);
20
+ }
21
+ /**
22
+ * The registry-owned machinery an entity mutates through. Entities never see
23
+ * the registry itself — only the open table, the canonical session-path
24
+ * index backing the `sessionIds` projection, and attach-time header reads.
25
+ */
26
+ export interface WorkspaceEntityHost {
27
+ /**
28
+ * Resolve the open `workspaces` table.
29
+ * @returns the table; throws while the registry has not started yet.
30
+ */
31
+ table(): KvTable<WorkspaceId, WorkspaceRecord>;
32
+ /**
33
+ * Read a session's canonical directory from the registry's header index.
34
+ * @param id - Session whose indexed path is requested.
35
+ * @returns the canonical directory, or `undefined` when the header is
36
+ * missing or its cwd cannot identify an existing directory.
37
+ */
38
+ sessionPath(id: SessionId): string | undefined;
39
+ /**
40
+ * Read one stored session header for attach validation.
41
+ * @param id - The session whose header to read.
42
+ * @returns the header; rejects when session persistence is absent or holds
43
+ * no session with this id.
44
+ */
45
+ readSessionHeader(id: SessionId): Promise<SessionHeader>;
46
+ /**
47
+ * Publish a successfully validated canonical cwd to the projection index.
48
+ * @param id - Validated session id.
49
+ * @param path - Canonical existing directory from the immutable header cwd.
50
+ */
51
+ rememberSessionPath(id: SessionId, path: string): void;
52
+ }
53
+ /** The single {@link Workspace} implementation; constructed only by the registry. */
54
+ export declare class WorkspaceEntity implements Workspace {
55
+ private readonly host;
56
+ readonly id: WorkspaceId;
57
+ private record;
58
+ /**
59
+ * @param host - Registry-owned table, session-path index, and header reads.
60
+ * @param id - The record's stable id.
61
+ * @param record - The validated record snapshot loaded or just written.
62
+ */
63
+ constructor(host: WorkspaceEntityHost, id: WorkspaceId, record: WorkspaceRecord);
64
+ get path(): string;
65
+ get title(): string;
66
+ get createdAt(): string;
67
+ get updatedAt(): string;
68
+ get sessionIds(): readonly SessionId[];
69
+ setTitle(title: string): Promise<void>;
70
+ attachSession(sessionId: SessionId): Promise<void>;
71
+ insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>;
72
+ detachSession(sessionId: SessionId): Promise<void>;
73
+ status(): Promise<'ok' | 'missing-dir'>;
74
+ /**
75
+ * The single write path: run `fn` on the domain write chain via
76
+ * `table.update`, stamping `updatedAt` and pruning candidates that no
77
+ * longer pass the id-plus-canonical-cwd membership check, then swap the
78
+ * snapshot.
79
+ *
80
+ * `fn` sees the value current at its chain slot, so membership decisions
81
+ * (attach/detach idempotence) are race-free against queued writes; a fn
82
+ * signalling no change by returning `current` verbatim aborts the slot
83
+ * through the sentinel when pruning also finds nothing, so a no-op neither
84
+ * rewrites the medium nor emits a change event.
85
+ */
86
+ private mutate;
87
+ }
88
+ //# sourceMappingURL=entity.d.ts.map
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Package-private workspace entity: the single {@link Workspace}
3
+ * implementation. Holds a record snapshot that is swapped in place after each
4
+ * durable mutation; every write funnels through the private `mutate` so
5
+ * `updatedAt` stamping and invalid-account pruning happen exactly once.
6
+ * Not re-exported from the package entrypoint — consumers see only the
7
+ * `Workspace` interface.
8
+ * @module @prettier-ai/dsh-workspace/src/entity
9
+ */
10
+ import { stat } from 'node:fs/promises';
11
+ import { realpathNormalize } from "./paths.js";
12
+ /** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */
13
+ export class WorkspaceMoveInvalidError extends Error {
14
+ /**
15
+ * @param message - Which id was unaccounted and where.
16
+ */
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = 'WorkspaceMoveInvalidError';
20
+ }
21
+ }
22
+ /** Chain-slot abort sentinel thrown by the update fn when the record needs no change; only `mutate` observes it. */
23
+ const unchangedSentinel = new Error('workspace record unchanged (internal sentinel)');
24
+ /** The single {@link Workspace} implementation; constructed only by the registry. */
25
+ export class WorkspaceEntity {
26
+ host;
27
+ id;
28
+ record;
29
+ /**
30
+ * @param host - Registry-owned table, session-path index, and header reads.
31
+ * @param id - The record's stable id.
32
+ * @param record - The validated record snapshot loaded or just written.
33
+ */
34
+ constructor(host, id, record) {
35
+ this.host = host;
36
+ this.id = id;
37
+ this.record = record;
38
+ }
39
+ get path() {
40
+ return this.record.path;
41
+ }
42
+ get title() {
43
+ return this.record.title;
44
+ }
45
+ get createdAt() {
46
+ return this.record.createdAt;
47
+ }
48
+ get updatedAt() {
49
+ return this.record.updatedAt;
50
+ }
51
+ get sessionIds() {
52
+ return this.record.sessionIds.filter(id => this.host.sessionPath(id) === this.record.path);
53
+ }
54
+ async setTitle(title) {
55
+ await this.mutate(record => ({ ...record, title }));
56
+ }
57
+ async attachSession(sessionId) {
58
+ // Validation is skipped when the settled snapshot already accounts the
59
+ // id: the cwd fact was checked when it first attached and both inputs
60
+ // (stored header cwd, workspace path) are immutable. Membership itself is
61
+ // decided on the write chain inside `mutate`, never on this snapshot.
62
+ if (!this.record.sessionIds.includes(sessionId)) {
63
+ const header = await this.host.readSessionHeader(sessionId);
64
+ if (header.cwd === undefined) {
65
+ throw new Error(`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
66
+ + 'its stored header carries no cwd to validate against');
67
+ }
68
+ let cwd;
69
+ try {
70
+ cwd = await realpathNormalize(header.cwd);
71
+ }
72
+ catch (error) {
73
+ throw new Error(`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
74
+ + `its cwd '${header.cwd}' does not resolve, so it cannot be validated`, { cause: error });
75
+ }
76
+ if (!(await stat(cwd)).isDirectory()) {
77
+ throw new Error(`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
78
+ + `its cwd '${header.cwd}' is not a directory`);
79
+ }
80
+ if (cwd !== this.record.path) {
81
+ throw new Error(`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
82
+ + `its cwd resolves to '${cwd}'`);
83
+ }
84
+ this.host.rememberSessionPath(sessionId, cwd);
85
+ }
86
+ await this.mutate(record => record.sessionIds.includes(sessionId)
87
+ ? record
88
+ : { ...record, sessionIds: [sessionId, ...record.sessionIds] });
89
+ }
90
+ async insertSessionBefore(sessionId, beforeSessionId) {
91
+ await this.mutate((record) => {
92
+ if (!record.sessionIds.includes(sessionId)) {
93
+ throw new WorkspaceMoveInvalidError(`cannot move session '${sessionId}' in workspace '${record.path}': the session is not accounted`);
94
+ }
95
+ if (beforeSessionId !== undefined && !record.sessionIds.includes(beforeSessionId)) {
96
+ throw new WorkspaceMoveInvalidError(`cannot move session '${sessionId}' before '${beforeSessionId}' in workspace '${record.path}': `
97
+ + 'the anchor session is not accounted');
98
+ }
99
+ if (beforeSessionId === sessionId)
100
+ return record;
101
+ const without = record.sessionIds.filter(id => id !== sessionId);
102
+ const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId);
103
+ const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)];
104
+ return sessionIds.every((id, index) => id === record.sessionIds[index])
105
+ ? record
106
+ : { ...record, sessionIds };
107
+ });
108
+ }
109
+ async detachSession(sessionId) {
110
+ await this.mutate(record => record.sessionIds.includes(sessionId)
111
+ ? { ...record, sessionIds: record.sessionIds.filter(id => id !== sessionId) }
112
+ : record);
113
+ }
114
+ async status() {
115
+ try {
116
+ return (await stat(this.record.path)).isDirectory() ? 'ok' : 'missing-dir';
117
+ }
118
+ catch {
119
+ // Any stat failure (ENOENT, dangling parent, permission loss) means the
120
+ // directory is not usable right now; the record itself never mutates.
121
+ return 'missing-dir';
122
+ }
123
+ }
124
+ /**
125
+ * The single write path: run `fn` on the domain write chain via
126
+ * `table.update`, stamping `updatedAt` and pruning candidates that no
127
+ * longer pass the id-plus-canonical-cwd membership check, then swap the
128
+ * snapshot.
129
+ *
130
+ * `fn` sees the value current at its chain slot, so membership decisions
131
+ * (attach/detach idempotence) are race-free against queued writes; a fn
132
+ * signalling no change by returning `current` verbatim aborts the slot
133
+ * through the sentinel when pruning also finds nothing, so a no-op neither
134
+ * rewrites the medium nor emits a change event.
135
+ */
136
+ async mutate(fn) {
137
+ let next;
138
+ try {
139
+ next = await this.host.table().update(this.id, (current) => {
140
+ const changed = fn(current);
141
+ const sessionIds = changed.sessionIds.filter(id => this.host.sessionPath(id) === changed.path);
142
+ if (changed === current && sessionIds.length === current.sessionIds.length) {
143
+ throw unchangedSentinel;
144
+ }
145
+ return { ...changed, sessionIds, updatedAt: new Date().toISOString() };
146
+ });
147
+ }
148
+ catch (error) {
149
+ if (error === unchangedSentinel)
150
+ return;
151
+ throw error;
152
+ }
153
+ this.record = next;
154
+ }
155
+ }
156
+ //# sourceMappingURL=entity.js.map
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Workspace entity registry (`ctx.workspaceRegistry`): durable workspace records,
3
+ * stable registry order, and header-validated session membership over the
4
+ * domain data form.
5
+ * @module @prettier-ai/dsh-workspace
6
+ */
7
+ import { Context, Service } from '@prettier-ai/cordis';
8
+ import type { SessionId } from '@prettier-ai/dsh-session';
9
+ export { WorkspaceMoveInvalidError } from './entity.ts';
10
+ import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts';
11
+ export type { Workspace } from './types.ts';
12
+ export { workspaceDomainState, workspaceRecord, workspaceDomainSpec } from './spec.ts';
13
+ export type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts';
14
+ export { realpathNormalize } from './paths.ts';
15
+ /** Identifies one workspace record (see `src/types.ts` for the brand rationale). */
16
+ export type WorkspaceId = WorkspaceIdBrand;
17
+ /**
18
+ * Brand a string as a {@link WorkspaceId}.
19
+ * @param id - Raw workspace id string.
20
+ * @returns the same string, branded at compile time.
21
+ */
22
+ export declare function WorkspaceId(id: string): WorkspaceId;
23
+ /**
24
+ * An archiveSession request named a session neither live nor in session
25
+ * persistence — a definite miss only; storage faults propagate as themselves.
26
+ */
27
+ export declare class WorkspaceUnknownSessionError extends Error {
28
+ readonly sessionId: SessionId;
29
+ /**
30
+ * @param sessionId - The unknown session id.
31
+ */
32
+ constructor(sessionId: SessionId);
33
+ }
34
+ /** A workspace reorder named a source or anchor absent from the durable registry order. */
35
+ export declare class WorkspaceOrderInvalidError extends Error {
36
+ readonly workspaceId: WorkspaceId;
37
+ /**
38
+ * @param workspaceId - Missing source or anchor id.
39
+ */
40
+ constructor(workspaceId: WorkspaceId);
41
+ }
42
+ declare module '@prettier-ai/cordis' {
43
+ interface Context {
44
+ workspaceRegistry: WorkspaceRegistry;
45
+ }
46
+ }
47
+ /**
48
+ * Durable workspace registry. Startup waits for `sessionPersistence`, builds
49
+ * one canonical-cwd header index, and completes the one-time history
50
+ * bootstrap before the service becomes active. The persistence dependency is
51
+ * mandatory so an unavailable peer can never be mistaken for an empty
52
+ * history and commit the initialized marker.
53
+ */
54
+ export declare class WorkspaceRegistry extends Service {
55
+ static inject: string[];
56
+ private table?;
57
+ private global?;
58
+ private state?;
59
+ private readonly entities;
60
+ private readonly headers;
61
+ private readonly sessionPaths;
62
+ private readonly invalidSessionPaths;
63
+ private operationTail;
64
+ private readonly host;
65
+ constructor(ctx: Context);
66
+ /** Open the domain, finish bootstrap when required, and rebuild the ordered cache. */
67
+ protected [Service.init](): Promise<void>;
68
+ /**
69
+ * Create or reuse a workspace for an existing directory. The path is
70
+ * canonicalized through `fs.realpath`; a nonexistent path rejects with the
71
+ * original error and a non-directory rejects. Repeated calls for the same
72
+ * canonical path return the existing entity without changing its title.
73
+ * A newly created workspace is prepended to the durable registry order.
74
+ * Different canonical paths may share a display title.
75
+ * @param path - Existing directory to own, in any path spelling.
76
+ * @param title - Display title used only when a new record is created.
77
+ * @returns the existing or newly durable workspace.
78
+ */
79
+ create(path: string, title?: string): Promise<Workspace>;
80
+ /**
81
+ * Look up a workspace by id.
82
+ * @param id - Workspace id.
83
+ * @returns the workspace, or `undefined` when unknown.
84
+ */
85
+ get(id: WorkspaceId): Workspace | undefined;
86
+ /**
87
+ * Synchronous workspace projection in durable registry order. Every
88
+ * entity's `sessionIds` getter is already filtered by the startup/live
89
+ * canonical-cwd header index; this method performs no persistence reads.
90
+ * @returns a fresh ordered array of workspace entities.
91
+ */
92
+ list(): Workspace[];
93
+ /**
94
+ * Delete one workspace registration while retaining its directory and every
95
+ * session log. The durable order is updated before the table deletion; a
96
+ * failed table write restores the prior order and keeps the entity
97
+ * published. Unknown ids are an idempotent no-op for domain callers.
98
+ * @param id - Workspace registration to remove.
99
+ * @returns `true` when a record was deleted, `false` when it was unknown.
100
+ */
101
+ delete(id: WorkspaceId): Promise<boolean>;
102
+ /**
103
+ * Move one workspace within the durable display order, DOM-insertBefore-like.
104
+ * With an anchor it lands before that workspace; without one it appends.
105
+ * @param id - Workspace to move.
106
+ * @param beforeId - Workspace anchor; omitted appends.
107
+ * @returns the complete committed workspace order.
108
+ */
109
+ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly WorkspaceId[]>;
110
+ /**
111
+ * The registry-global archive set: sessions hidden from every grouping
112
+ * surface. Archiving never touches workspace accounting — an archived
113
+ * session keeps its `sessionIds` slot so unarchiving restores its position.
114
+ * @returns the archived session ids in archive order.
115
+ */
116
+ get archivedSessionIds(): readonly SessionId[];
117
+ /**
118
+ * Archive one session durably. The session must exist (live or in session
119
+ * persistence); its workspace accounting — or lack of one — is irrelevant.
120
+ * An already archived id resolves without writing.
121
+ * @param sessionId - The session to archive.
122
+ * @returns resolution after durability.
123
+ */
124
+ archiveSession(sessionId: SessionId): Promise<void>;
125
+ /**
126
+ * Whether a session is live, header-indexed, or present in a fresh
127
+ * persistence listing. Only a definite miss returns false — a failing
128
+ * `sessionPersistence.list()` propagates so storage faults never
129
+ * masquerade as an unknown session.
130
+ */
131
+ private sessionKnown;
132
+ /**
133
+ * Resolve by canonical directory path without creating or mutating a
134
+ * workspace. A missing path rejects during `realpath`; an existing unowned
135
+ * directory returns `undefined`.
136
+ * @param path - Existing directory path in any spelling.
137
+ * @returns the workspace owning the canonical path, when one exists.
138
+ */
139
+ resolveByPath(path: string): Promise<Workspace | undefined>;
140
+ private createCanonical;
141
+ private deleteKnown;
142
+ /**
143
+ * Complete the one mutation explicitly named by durable state. Unexplained
144
+ * order/table divergence still reaches {@link validateStoredState} and
145
+ * fails loud; this path never guesses which operation created a row from its shape alone.
146
+ */
147
+ private recoverPendingMutation;
148
+ private bootstrap;
149
+ private validateStoredState;
150
+ private rebuildEntities;
151
+ private replaceHeaderIndex;
152
+ private indexHeaders;
153
+ private indexHeader;
154
+ private indexLiveSessions;
155
+ private reportFilteredCandidates;
156
+ private readSessionHeader;
157
+ private requireTable;
158
+ private requireState;
159
+ private setState;
160
+ private enqueueOperation;
161
+ }
162
+ export default WorkspaceRegistry;
163
+ //# sourceMappingURL=index.d.ts.map