@orkestrel/workspace 0.0.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,946 @@
1
+ import { arrayOf, holds, isArray, isNumber, isRecord, isString, literalOf, rawShape, stringShape } from "@orkestrel/contract";
2
+ import { createDatabase, createMemoryDriver } from "@orkestrel/database";
3
+ import { Emitter } from "@orkestrel/emitter";
4
+ //#region src/core/constants.ts
5
+ /**
6
+ * File extensions mapped to language tags for text content.
7
+ *
8
+ * Unknown extensions intentionally fall back to `text` in
9
+ * {@link import('./helpers.js').inferLanguage}.
10
+ */
11
+ var EXTENSION_LANGUAGES = Object.freeze({
12
+ ts: "typescript",
13
+ tsx: "typescript",
14
+ js: "javascript",
15
+ jsx: "javascript",
16
+ mjs: "javascript",
17
+ cjs: "javascript",
18
+ json: "json",
19
+ md: "markdown",
20
+ html: "html",
21
+ htm: "html",
22
+ css: "css",
23
+ scss: "scss",
24
+ sass: "sass",
25
+ less: "less",
26
+ vue: "vue",
27
+ svelte: "svelte",
28
+ py: "python",
29
+ rb: "ruby",
30
+ rs: "rust",
31
+ go: "go",
32
+ java: "java",
33
+ kt: "kotlin",
34
+ swift: "swift",
35
+ c: "c",
36
+ cpp: "cpp",
37
+ h: "c",
38
+ hpp: "cpp",
39
+ cs: "csharp",
40
+ php: "php",
41
+ sql: "sql",
42
+ sh: "bash",
43
+ bash: "bash",
44
+ zsh: "bash",
45
+ ps1: "powershell",
46
+ yaml: "yaml",
47
+ yml: "yaml",
48
+ toml: "toml",
49
+ xml: "xml",
50
+ svg: "xml",
51
+ txt: "text"
52
+ });
53
+ //#endregion
54
+ //#region src/core/errors.ts
55
+ /** An invalid workspace edit or search operation. */
56
+ var WorkspaceError = class extends Error {
57
+ code;
58
+ context;
59
+ /**
60
+ * Create a workspace error.
61
+ *
62
+ * @param code - The machine-readable failure code
63
+ * @param message - The human-readable failure message
64
+ * @param context - Optional values associated with the failed operation
65
+ */
66
+ constructor(code, message, context) {
67
+ super(message);
68
+ this.name = "WorkspaceError";
69
+ this.code = code;
70
+ if (context !== void 0) this.context = context;
71
+ }
72
+ };
73
+ /**
74
+ * Narrow a caught value to a {@link WorkspaceError}.
75
+ *
76
+ * @param value - The caught value
77
+ * @returns Whether the value is a workspace error
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * if (isWorkspaceError(error)) error.code
82
+ * ```
83
+ */
84
+ function isWorkspaceError(value) {
85
+ return value instanceof WorkspaceError;
86
+ }
87
+ //#endregion
88
+ //#region src/core/helpers.ts
89
+ /**
90
+ * Infer a language tag from the final file extension.
91
+ *
92
+ * @param path - The file path
93
+ * @returns The mapped language, or `text` when the extension is absent or unknown
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * inferLanguage('src/main.ts') // 'typescript'
98
+ * ```
99
+ */
100
+ function inferLanguage(path) {
101
+ const dot = path.lastIndexOf(".");
102
+ if (dot === -1) return "text";
103
+ return EXTENSION_LANGUAGES[path.slice(dot + 1).toLowerCase()] ?? "text";
104
+ }
105
+ /**
106
+ * Determine whether content is the text arm.
107
+ *
108
+ * @param content - The file content
109
+ * @returns Whether the content carries text and a language
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * isText({ text: 'hello', language: 'text' }) // true
114
+ * ```
115
+ */
116
+ function isText(content) {
117
+ return "text" in content;
118
+ }
119
+ /**
120
+ * Determine whether content is the binary arm.
121
+ *
122
+ * @param content - The file content
123
+ * @returns Whether the content carries base64 data and a MIME
124
+ *
125
+ * @example
126
+ * ```ts
127
+ * isBinary({ data: 'AAAA', mime: 'image/png' }) // true
128
+ * ```
129
+ */
130
+ function isBinary(content) {
131
+ return "data" in content;
132
+ }
133
+ /**
134
+ * Narrow an unknown value to an immutable file record.
135
+ *
136
+ * @param value - The value to inspect
137
+ * @returns Whether the value has the file shape
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * isFile({ path: 'a.txt', content: { text: 'a', language: 'text' }, state: 'created', size: 1, lines: 1 })
142
+ * ```
143
+ */
144
+ function isFile(value) {
145
+ return holds(() => {
146
+ if (!isRecord(value)) return false;
147
+ if (!isString(value.path) || !literalOf("created", "modified")(value.state)) return false;
148
+ if (!isNumber(value.size) || !isNumber(value.lines)) return false;
149
+ if (!isRecord(value.content)) return false;
150
+ const text = isString(value.content.text) && isString(value.content.language);
151
+ const binary = isString(value.content.data) && literalOf("image/png", "image/jpeg", "image/gif", "image/webp")(value.content.mime);
152
+ return text || binary;
153
+ });
154
+ }
155
+ /**
156
+ * Narrow an unknown value to a workspace snapshot.
157
+ *
158
+ * @param value - The value read from a persistence boundary
159
+ * @returns Whether the value has the workspace snapshot shape
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * isWorkspaceSnapshot({ id: 'work', files: [] }) // true
164
+ * ```
165
+ */
166
+ function isWorkspaceSnapshot(value) {
167
+ return holds(() => isRecord(value) && isString(value.id) && arrayOf(isFile)(value.files));
168
+ }
169
+ /**
170
+ * Compute the byte size of file content.
171
+ *
172
+ * @param content - The content to measure
173
+ * @returns UTF-8 bytes for text or decoded bytes for binary content
174
+ *
175
+ * @example
176
+ * ```ts
177
+ * computeSize({ text: 'café', language: 'text' }) // 5
178
+ * ```
179
+ */
180
+ function computeSize(content) {
181
+ if (isText(content)) return new TextEncoder().encode(content.text).length;
182
+ return decodedSize(content.data);
183
+ }
184
+ /**
185
+ * Count the lines in file content.
186
+ *
187
+ * @param content - The content to inspect
188
+ * @returns The text line count, or zero for empty text and binary content
189
+ *
190
+ * @example
191
+ * ```ts
192
+ * countLines({ text: 'a\nb', language: 'text' }) // 2
193
+ * ```
194
+ */
195
+ function countLines(content) {
196
+ if (!isText(content)) return 0;
197
+ if (content.text.length === 0) return 0;
198
+ let count = 1;
199
+ for (const character of content.text) if (character === "\n") count += 1;
200
+ return count;
201
+ }
202
+ /**
203
+ * Compute the decoded byte length of a base64 string.
204
+ *
205
+ * @param base64 - The base64 data
206
+ * @returns The decoded byte count
207
+ *
208
+ * @example
209
+ * ```ts
210
+ * decodedSize('AAAA') // 3
211
+ * ```
212
+ */
213
+ function decodedSize(base64) {
214
+ let padding = 0;
215
+ if (base64.endsWith("==")) padding = 2;
216
+ else if (base64.endsWith("=")) padding = 1;
217
+ return Math.floor(base64.length * 3 / 4) - padding;
218
+ }
219
+ /**
220
+ * Determine whether a 1-based range is structurally valid.
221
+ *
222
+ * @param range - The range to inspect
223
+ * @returns Whether both positions are positive and ordered
224
+ *
225
+ * @example
226
+ * ```ts
227
+ * isValidRange({ start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }) // true
228
+ * ```
229
+ */
230
+ function isValidRange(range) {
231
+ if (range.start.line < 1 || range.start.column < 1) return false;
232
+ if (range.end.line < 1 || range.end.column < 1) return false;
233
+ if (range.start.line > range.end.line) return false;
234
+ return !(range.start.line === range.end.line && range.start.column > range.end.column);
235
+ }
236
+ /**
237
+ * Clamp a position to text bounds.
238
+ *
239
+ * @param text - The addressed text
240
+ * @param position - The requested position
241
+ * @returns The clamped position
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * clampPosition('ab', { line: 9, column: 9 }) // { line: 1, column: 3 }
246
+ * ```
247
+ */
248
+ function clampPosition(text, position) {
249
+ const lines = text.split("\n");
250
+ const line = Math.max(1, Math.min(position.line, lines.length));
251
+ const lineText = lines[line - 1] ?? "";
252
+ return {
253
+ line,
254
+ column: Math.max(1, Math.min(position.column, lineText.length + 1))
255
+ };
256
+ }
257
+ /**
258
+ * Clamp both positions in a range to text bounds.
259
+ *
260
+ * @param text - The addressed text
261
+ * @param range - The requested range
262
+ * @returns The clamped range
263
+ *
264
+ * @example
265
+ * ```ts
266
+ * clampRange('ab', { start: { line: 1, column: 1 }, end: { line: 9, column: 9 } })
267
+ * ```
268
+ */
269
+ function clampRange(text, range) {
270
+ return {
271
+ start: clampPosition(text, range.start),
272
+ end: clampPosition(text, range.end)
273
+ };
274
+ }
275
+ /**
276
+ * Convert a 1-based position to a zero-based string offset.
277
+ *
278
+ * @param text - The addressed text
279
+ * @param position - The requested position
280
+ * @returns The bounded string offset
281
+ *
282
+ * @example
283
+ * ```ts
284
+ * offsetAt('ab\ncd', { line: 2, column: 1 }) // 3
285
+ * ```
286
+ */
287
+ function offsetAt(text, position) {
288
+ const lines = text.split("\n");
289
+ let offset = 0;
290
+ for (let index = 0; index < position.line - 1 && index < lines.length; index += 1) offset += (lines[index]?.length ?? 0) + 1;
291
+ offset += position.column - 1;
292
+ return Math.min(offset, text.length);
293
+ }
294
+ /**
295
+ * Slice a clamped half-open text range.
296
+ *
297
+ * @param text - The source text
298
+ * @param range - The requested range
299
+ * @returns The ranged substring
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * sliceRange('hello', { start: { line: 1, column: 1 }, end: { line: 1, column: 6 } }) // 'hello'
304
+ * ```
305
+ */
306
+ function sliceRange(text, range) {
307
+ const clamped = clampRange(text, range);
308
+ return text.slice(offsetAt(text, clamped.start), offsetAt(text, clamped.end));
309
+ }
310
+ /**
311
+ * Replace a clamped half-open text range.
312
+ *
313
+ * @param text - The source text
314
+ * @param range - The requested range
315
+ * @param replacement - The replacement text
316
+ * @returns The spliced text
317
+ *
318
+ * @example
319
+ * ```ts
320
+ * spliceRange('hello', { start: { line: 1, column: 1 }, end: { line: 1, column: 6 } }, 'bye')
321
+ * ```
322
+ */
323
+ function spliceRange(text, range, replacement) {
324
+ const clamped = clampRange(text, range);
325
+ const start = offsetAt(text, clamped.start);
326
+ const end = offsetAt(text, clamped.end);
327
+ return text.slice(0, start) + replacement + text.slice(end);
328
+ }
329
+ /**
330
+ * Assemble a nested range from four 1-based coordinates.
331
+ *
332
+ * @param fromLine - The start line
333
+ * @param fromColumn - The start column
334
+ * @param toLine - The end line
335
+ * @param toColumn - The end column
336
+ * @returns The assembled range without validation
337
+ *
338
+ * @example
339
+ * ```ts
340
+ * rangeOf(1, 1, 1, 2)
341
+ * ```
342
+ */
343
+ function rangeOf(fromLine, fromColumn, toLine, toColumn) {
344
+ return {
345
+ start: {
346
+ line: fromLine,
347
+ column: fromColumn
348
+ },
349
+ end: {
350
+ line: toLine,
351
+ column: toColumn
352
+ }
353
+ };
354
+ }
355
+ /**
356
+ * Escape regular-expression metacharacters for literal matching.
357
+ *
358
+ * @param value - The literal text
359
+ * @returns The escaped regular-expression source
360
+ *
361
+ * @example
362
+ * ```ts
363
+ * escapeRegExp('a.b') // 'a\\.b'
364
+ * ```
365
+ */
366
+ function escapeRegExp(value) {
367
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
368
+ }
369
+ //#endregion
370
+ //#region src/core/workspaces/Workspace.ts
371
+ /**
372
+ * A mutable path-keyed editing surface over immutable files.
373
+ *
374
+ * Whole-file edits create text files, ranged edits operate only on existing text files, and
375
+ * binary files remain available through construction-time hydration. Mutations emit after the
376
+ * registry has changed.
377
+ *
378
+ * @example
379
+ * ```ts
380
+ * import { Workspace } from '@orkestrel/workspace'
381
+ *
382
+ * const workspace = new Workspace()
383
+ * workspace.write('notes.txt', 'hello')
384
+ * workspace.append('notes.txt', ' world')
385
+ * workspace.read('notes.txt') // 'hello world'
386
+ * ```
387
+ */
388
+ var Workspace = class {
389
+ #id;
390
+ #files = /* @__PURE__ */ new Map();
391
+ #emitter;
392
+ /**
393
+ * Create a workspace.
394
+ *
395
+ * @param options - Optional identity, emitter configuration, and initial files
396
+ */
397
+ constructor(options) {
398
+ this.#id = options?.id ?? crypto.randomUUID();
399
+ this.#emitter = new Emitter({
400
+ ...options?.on === void 0 ? {} : { on: options.on },
401
+ ...options?.error === void 0 ? {} : { error: options.error }
402
+ });
403
+ if (options?.seed) for (const file of options.seed) this.#files.set(file.path, file);
404
+ }
405
+ get id() {
406
+ return this.#id;
407
+ }
408
+ get emitter() {
409
+ return this.#emitter;
410
+ }
411
+ get count() {
412
+ return this.#files.size;
413
+ }
414
+ file(path) {
415
+ return this.#files.get(path);
416
+ }
417
+ files() {
418
+ return [...this.#files.values()];
419
+ }
420
+ read(path, range) {
421
+ if (isArray(path)) {
422
+ const result = {};
423
+ for (const one of path) {
424
+ const file = this.#files.get(one);
425
+ if (file && isText(file.content)) result[one] = file.content.text;
426
+ }
427
+ return result;
428
+ }
429
+ const file = this.#files.get(path);
430
+ if (!file) return void 0;
431
+ if (!range) return isText(file.content) ? file.content.text : void 0;
432
+ if (!isText(file.content)) throw new WorkspaceError("MODALITY", `Cannot read a range of a binary file: ${path}`, { path });
433
+ return {
434
+ content: sliceRange(file.content.text, range),
435
+ range: clampRange(file.content.text, range)
436
+ };
437
+ }
438
+ has(path) {
439
+ if (isArray(path)) return path.some((one) => this.#files.has(one));
440
+ return this.#files.has(path);
441
+ }
442
+ search(query, options) {
443
+ const pattern = this.#pattern(query, options);
444
+ const limit = options?.limit;
445
+ const matches = [];
446
+ for (const file of this.#files.values()) {
447
+ if (limit !== void 0 && matches.length >= limit) break;
448
+ if (!isText(file.content)) continue;
449
+ const lines = file.content.text.split("\n");
450
+ for (let index = 0; index < lines.length; index += 1) {
451
+ if (limit !== void 0 && matches.length >= limit) break;
452
+ const lineText = lines[index] ?? "";
453
+ pattern.lastIndex = 0;
454
+ let hit = pattern.exec(lineText);
455
+ while (hit !== null) {
456
+ if (limit !== void 0 && matches.length >= limit) break;
457
+ matches.push({
458
+ path: file.path,
459
+ line: index + 1,
460
+ column: hit.index + 1,
461
+ length: hit[0].length,
462
+ content: lineText
463
+ });
464
+ if (hit[0].length === 0) pattern.lastIndex += 1;
465
+ hit = pattern.exec(lineText);
466
+ }
467
+ }
468
+ }
469
+ return matches;
470
+ }
471
+ replace(query, replacement, options) {
472
+ const pattern = this.#pattern(query, options);
473
+ const limit = options?.limit;
474
+ let occurrences = 0;
475
+ let files = 0;
476
+ for (const [path, file] of this.#files) {
477
+ if (limit !== void 0 && occurrences >= limit) break;
478
+ if (!isText(file.content)) continue;
479
+ const remaining = limit === void 0 ? void 0 : limit - occurrences;
480
+ let count = 0;
481
+ pattern.lastIndex = 0;
482
+ const next = file.content.text.replace(pattern, (match) => {
483
+ if (remaining !== void 0 && count >= remaining) return match;
484
+ count += 1;
485
+ return replacement;
486
+ });
487
+ if (count > 0) {
488
+ occurrences += count;
489
+ files += 1;
490
+ this.write(path, next);
491
+ }
492
+ }
493
+ return {
494
+ occurrences,
495
+ files
496
+ };
497
+ }
498
+ write(path, content, range) {
499
+ if (isRecord(path)) {
500
+ for (const [one, text] of Object.entries(path)) this.#write(one, text);
501
+ return;
502
+ }
503
+ const text = content ?? "";
504
+ if (!range) {
505
+ this.#write(path, text);
506
+ return;
507
+ }
508
+ this.#splice(path, text, range);
509
+ }
510
+ prepend(path, content) {
511
+ if (isRecord(path)) {
512
+ for (const [one, text] of Object.entries(path)) this.#prepend(one, text);
513
+ return;
514
+ }
515
+ this.#prepend(path, content ?? "");
516
+ }
517
+ append(path, content) {
518
+ if (isRecord(path)) {
519
+ for (const [one, text] of Object.entries(path)) this.#append(one, text);
520
+ return;
521
+ }
522
+ this.#append(path, content ?? "");
523
+ }
524
+ move(from, to) {
525
+ if (isRecord(from)) {
526
+ let moved = false;
527
+ for (const [one, target] of Object.entries(from)) if (this.#move(one, target)) moved = true;
528
+ return moved;
529
+ }
530
+ return this.#move(from, to ?? "");
531
+ }
532
+ remove(path) {
533
+ if (isArray(path)) {
534
+ let removed = false;
535
+ for (const one of path) if (this.#remove(one)) removed = true;
536
+ return removed;
537
+ }
538
+ return this.#remove(path);
539
+ }
540
+ clear() {
541
+ this.#files.clear();
542
+ this.#emitter.emit("clear");
543
+ }
544
+ snapshot() {
545
+ return {
546
+ id: this.#id,
547
+ files: this.files()
548
+ };
549
+ }
550
+ destroy() {
551
+ this.#emitter.destroy();
552
+ }
553
+ #write(path, content) {
554
+ const existing = this.#files.get(path);
555
+ const file = createFile({
556
+ path,
557
+ content: {
558
+ text: content,
559
+ language: existing && isText(existing.content) ? existing.content.language : inferLanguage(path)
560
+ },
561
+ state: existing ? "modified" : "created"
562
+ });
563
+ this.#files.set(path, file);
564
+ this.#emitter.emit("write", file);
565
+ }
566
+ #splice(path, content, range) {
567
+ const existing = this.#files.get(path);
568
+ if (!existing) throw new WorkspaceError("MODALITY", `Cannot splice a range of a missing file: ${path}`, { path });
569
+ if (!isText(existing.content)) throw new WorkspaceError("MODALITY", `Cannot splice a range of a binary file: ${path}`, { path });
570
+ if (!isValidRange(range)) throw new WorkspaceError("RANGE", `Invalid range for file: ${path}`, {
571
+ path,
572
+ range
573
+ });
574
+ const file = createFile({
575
+ path,
576
+ content: {
577
+ text: spliceRange(existing.content.text, range, content),
578
+ language: existing.content.language
579
+ },
580
+ state: "modified"
581
+ });
582
+ this.#files.set(path, file);
583
+ this.#emitter.emit("write", file);
584
+ }
585
+ #prepend(path, content) {
586
+ const existing = this.#files.get(path);
587
+ let text = "";
588
+ if (existing) {
589
+ if (!isText(existing.content)) throw new WorkspaceError("MODALITY", `Cannot prepend text to a binary file: ${path}`, { path });
590
+ text = existing.content.text;
591
+ }
592
+ this.#write(path, content + text);
593
+ }
594
+ #append(path, content) {
595
+ const existing = this.#files.get(path);
596
+ let text = "";
597
+ if (existing) {
598
+ if (!isText(existing.content)) throw new WorkspaceError("MODALITY", `Cannot append text to a binary file: ${path}`, { path });
599
+ text = existing.content.text;
600
+ }
601
+ this.#write(path, text + content);
602
+ }
603
+ #move(from, to) {
604
+ if (from === to) return false;
605
+ const file = this.#files.get(from);
606
+ if (!file) return false;
607
+ const moved = createFile({
608
+ path: to,
609
+ content: file.content,
610
+ state: "modified"
611
+ });
612
+ const entries = [...this.#files];
613
+ this.#files.clear();
614
+ for (const [path, current] of entries) if (path === from) this.#files.set(to, moved);
615
+ else if (path !== to) this.#files.set(path, current);
616
+ this.#emitter.emit("move", from, to);
617
+ return true;
618
+ }
619
+ #remove(path) {
620
+ const removed = this.#files.delete(path);
621
+ if (removed) this.#emitter.emit("remove", path);
622
+ return removed;
623
+ }
624
+ #pattern(query, options) {
625
+ const source = options?.regex === true ? query : escapeRegExp(query);
626
+ const flags = options?.sensitive === false ? "gi" : "g";
627
+ try {
628
+ return new RegExp(source, flags);
629
+ } catch {
630
+ throw new WorkspaceError("PATTERN", `Invalid search pattern: ${query}`, { query });
631
+ }
632
+ }
633
+ };
634
+ //#endregion
635
+ //#region src/core/workspaces/WorkspaceManager.ts
636
+ /**
637
+ * An insertion-ordered workspace registry with an active selection.
638
+ *
639
+ * A supplied store adds lenient snapshot `open` and `save` operations. Event defaults flow into
640
+ * workspaces created through the registry, while observability remains owned by each workspace.
641
+ *
642
+ * @example
643
+ * ```ts
644
+ * import { WorkspaceManager } from '@orkestrel/workspace'
645
+ *
646
+ * const manager = new WorkspaceManager()
647
+ * const workspace = manager.add({ id: 'scratch' })
648
+ * manager.active === workspace
649
+ * ```
650
+ */
651
+ var WorkspaceManager = class {
652
+ #workspaces = /* @__PURE__ */ new Map();
653
+ #active;
654
+ #on;
655
+ #error;
656
+ #store;
657
+ /**
658
+ * Create a registry.
659
+ *
660
+ * @param options - Default event hooks and optional snapshot store
661
+ */
662
+ constructor(options) {
663
+ this.#on = options?.on;
664
+ this.#error = options?.error;
665
+ this.#store = options?.store;
666
+ }
667
+ get count() {
668
+ return this.#workspaces.size;
669
+ }
670
+ get active() {
671
+ return this.#active === void 0 ? void 0 : this.#workspaces.get(this.#active);
672
+ }
673
+ workspace(id) {
674
+ return this.#workspaces.get(id);
675
+ }
676
+ workspaces() {
677
+ return [...this.#workspaces.values()];
678
+ }
679
+ add(options) {
680
+ const on = options?.on ?? this.#on;
681
+ const error = options?.error ?? this.#error;
682
+ const workspace = new Workspace({
683
+ ...options?.id === void 0 ? {} : { id: options.id },
684
+ ...on === void 0 ? {} : { on },
685
+ ...error === void 0 ? {} : { error },
686
+ ...options?.seed === void 0 ? {} : { seed: options.seed }
687
+ });
688
+ this.#workspaces.set(workspace.id, workspace);
689
+ if (this.#active === void 0) this.#active = workspace.id;
690
+ return workspace;
691
+ }
692
+ switch(id) {
693
+ const workspace = this.#workspaces.get(id);
694
+ if (workspace === void 0) return void 0;
695
+ this.#active = id;
696
+ return workspace;
697
+ }
698
+ async open(id) {
699
+ const existing = this.#workspaces.get(id);
700
+ if (existing !== void 0) {
701
+ this.#active = id;
702
+ return existing;
703
+ }
704
+ if (this.#store === void 0) return void 0;
705
+ const snapshot = await this.#store.get(id);
706
+ if (snapshot === void 0) return void 0;
707
+ const workspace = this.add({
708
+ id,
709
+ seed: snapshot.files
710
+ });
711
+ this.#active = workspace.id;
712
+ return workspace;
713
+ }
714
+ async save(id) {
715
+ const workspace = this.#workspaces.get(id);
716
+ if (this.#store === void 0 || workspace === void 0) return false;
717
+ await this.#store.set(workspace.snapshot());
718
+ return true;
719
+ }
720
+ remove(ids) {
721
+ if (isArray(ids)) {
722
+ let removed = false;
723
+ for (const id of ids) if (this.#drop(id)) removed = true;
724
+ return removed;
725
+ }
726
+ return this.#drop(ids);
727
+ }
728
+ clear() {
729
+ for (const workspace of this.#workspaces.values()) workspace.destroy();
730
+ this.#workspaces.clear();
731
+ this.#active = void 0;
732
+ }
733
+ #drop(id) {
734
+ const workspace = this.#workspaces.get(id);
735
+ if (workspace === void 0) return false;
736
+ this.#workspaces.delete(id);
737
+ workspace.destroy();
738
+ if (this.#active === id) this.#active = void 0;
739
+ return true;
740
+ }
741
+ };
742
+ //#endregion
743
+ //#region src/core/workspaces/stores/DatabaseWorkspaceStore.ts
744
+ /**
745
+ * A workspace snapshot store over a database table.
746
+ *
747
+ * Snapshots occupy one opaque column and are narrowed when read back from the storage boundary.
748
+ *
749
+ * @example
750
+ * ```ts
751
+ * import { createDatabaseWorkspaceStore } from '@orkestrel/workspace'
752
+ *
753
+ * const store = createDatabaseWorkspaceStore()
754
+ * ```
755
+ */
756
+ var DatabaseWorkspaceStore = class {
757
+ #table;
758
+ /**
759
+ * Wrap a workspace snapshot table.
760
+ *
761
+ * @param table - The table keyed by workspace identifier
762
+ */
763
+ constructor(table) {
764
+ this.#table = table;
765
+ }
766
+ async get(id) {
767
+ const row = await this.#table.get(id);
768
+ if (row === void 0) return void 0;
769
+ return isWorkspaceSnapshot(row.snapshot) ? row.snapshot : void 0;
770
+ }
771
+ async set(snapshot) {
772
+ await this.#table.set({
773
+ id: snapshot.id,
774
+ snapshot
775
+ });
776
+ }
777
+ async delete(id) {
778
+ await this.#table.remove(id);
779
+ }
780
+ };
781
+ //#endregion
782
+ //#region src/core/workspaces/stores/MemoryWorkspaceStore.ts
783
+ /**
784
+ * A process-local workspace snapshot store.
785
+ *
786
+ * @example
787
+ * ```ts
788
+ * import { MemoryWorkspaceStore } from '@orkestrel/workspace'
789
+ *
790
+ * const store = new MemoryWorkspaceStore()
791
+ * ```
792
+ */
793
+ var MemoryWorkspaceStore = class {
794
+ #snapshots = /* @__PURE__ */ new Map();
795
+ get(id) {
796
+ return Promise.resolve(this.#snapshots.get(id));
797
+ }
798
+ set(snapshot) {
799
+ this.#snapshots.set(snapshot.id, snapshot);
800
+ return Promise.resolve();
801
+ }
802
+ delete(id) {
803
+ this.#snapshots.delete(id);
804
+ return Promise.resolve();
805
+ }
806
+ };
807
+ //#endregion
808
+ //#region src/core/factories.ts
809
+ /**
810
+ * Create an immutable file with derived size and line counts.
811
+ *
812
+ * @param input - The file path, content, and optional state
813
+ * @returns A frozen file record
814
+ *
815
+ * @example
816
+ * ```ts
817
+ * import { createFile, createTextContent } from '@orkestrel/workspace'
818
+ *
819
+ * createFile({ path: 'a.txt', content: createTextContent('hello', 'text') })
820
+ * ```
821
+ */
822
+ function createFile(input) {
823
+ return Object.freeze({
824
+ path: input.path,
825
+ content: input.content,
826
+ state: input.state ?? "created",
827
+ size: computeSize(input.content),
828
+ lines: countLines(input.content)
829
+ });
830
+ }
831
+ /**
832
+ * Create the text arm of {@link FileContent}.
833
+ *
834
+ * @param text - The text body
835
+ * @param language - The language tag
836
+ * @returns Text file content
837
+ *
838
+ * @example
839
+ * ```ts
840
+ * import { createTextContent } from '@orkestrel/workspace'
841
+ *
842
+ * createTextContent('hello', 'text')
843
+ * ```
844
+ */
845
+ function createTextContent(text, language) {
846
+ return {
847
+ text,
848
+ language
849
+ };
850
+ }
851
+ /**
852
+ * Create the binary arm of {@link FileContent}.
853
+ *
854
+ * @param data - The base64 payload
855
+ * @param mime - The binary MIME
856
+ * @returns Binary file content
857
+ *
858
+ * @example
859
+ * ```ts
860
+ * import { createBinaryContent } from '@orkestrel/workspace'
861
+ *
862
+ * createBinaryContent('AAAA', 'image/png')
863
+ * ```
864
+ */
865
+ function createBinaryContent(data, mime) {
866
+ return {
867
+ data,
868
+ mime
869
+ };
870
+ }
871
+ /**
872
+ * Create a workspace.
873
+ *
874
+ * @param options - Optional identity, emitter configuration, and initial files
875
+ * @returns A working workspace
876
+ *
877
+ * @example
878
+ * ```ts
879
+ * import { createWorkspace } from '@orkestrel/workspace'
880
+ *
881
+ * const workspace = createWorkspace()
882
+ * workspace.write('a.txt', 'hello')
883
+ * ```
884
+ */
885
+ function createWorkspace(options) {
886
+ return new Workspace(options);
887
+ }
888
+ /**
889
+ * Create an in-memory workspace snapshot store.
890
+ *
891
+ * @returns A process-local workspace store
892
+ *
893
+ * @example
894
+ * ```ts
895
+ * import { createMemoryWorkspaceStore } from '@orkestrel/workspace'
896
+ *
897
+ * const store = createMemoryWorkspaceStore()
898
+ * ```
899
+ */
900
+ function createMemoryWorkspaceStore() {
901
+ return new MemoryWorkspaceStore();
902
+ }
903
+ /**
904
+ * Create a database-backed workspace snapshot store.
905
+ *
906
+ * @param driver - The database driver; defaults to an in-memory driver
907
+ * @returns A workspace store backed by the supplied driver
908
+ *
909
+ * @example
910
+ * ```ts
911
+ * import { createDatabaseWorkspaceStore } from '@orkestrel/workspace'
912
+ *
913
+ * const store = createDatabaseWorkspaceStore()
914
+ * ```
915
+ */
916
+ function createDatabaseWorkspaceStore(driver = createMemoryDriver()) {
917
+ const columns = {
918
+ id: stringShape(),
919
+ snapshot: rawShape({})
920
+ };
921
+ return new DatabaseWorkspaceStore(createDatabase({
922
+ driver,
923
+ tables: { workspaces: columns }
924
+ }).table("workspaces"));
925
+ }
926
+ /**
927
+ * Create an empty workspace registry.
928
+ *
929
+ * @param options - Default event hooks and optional durability
930
+ * @returns A workspace manager
931
+ *
932
+ * @example
933
+ * ```ts
934
+ * import { createWorkspaceManager } from '@orkestrel/workspace'
935
+ *
936
+ * const manager = createWorkspaceManager()
937
+ * manager.add()
938
+ * ```
939
+ */
940
+ function createWorkspaceManager(options) {
941
+ return new WorkspaceManager(options);
942
+ }
943
+ //#endregion
944
+ export { DatabaseWorkspaceStore, EXTENSION_LANGUAGES, MemoryWorkspaceStore, Workspace, WorkspaceError, WorkspaceManager, clampPosition, clampRange, computeSize, countLines, createBinaryContent, createDatabaseWorkspaceStore, createFile, createMemoryWorkspaceStore, createTextContent, createWorkspace, createWorkspaceManager, decodedSize, escapeRegExp, inferLanguage, isBinary, isFile, isText, isValidRange, isWorkspaceError, isWorkspaceSnapshot, offsetAt, rangeOf, sliceRange, spliceRange };
945
+
946
+ //# sourceMappingURL=index.js.map