@tnotesjs/core 0.7.0 → 0.8.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.
Files changed (55) hide show
  1. package/commands/BaseCommand.ts +58 -0
  2. package/commands/build/BuildCommand.ts +25 -0
  3. package/commands/build/PreviewCommand.ts +29 -0
  4. package/commands/build/index.ts +8 -0
  5. package/commands/dev/DevCommand.ts +75 -0
  6. package/commands/dev/index.ts +7 -0
  7. package/commands/git/PullCommand.ts +25 -0
  8. package/commands/git/PushCommand.ts +64 -0
  9. package/commands/git/index.ts +8 -0
  10. package/commands/index.ts +11 -0
  11. package/commands/init-sub-repo/InitSubRepoCommand.ts +206 -0
  12. package/commands/init-sub-repo/index.ts +1 -0
  13. package/commands/misc/HelpCommand.ts +104 -0
  14. package/commands/misc/index.ts +7 -0
  15. package/commands/models.ts +87 -0
  16. package/commands/note/CreateNoteCommand.ts +160 -0
  17. package/commands/note/RenameNoteCommand.ts +147 -0
  18. package/commands/note/UpdateNoteConfigCommand.ts +78 -0
  19. package/commands/note/index.ts +9 -0
  20. package/commands/registry.ts +47 -0
  21. package/commands/update/UpdateCommand.ts +219 -0
  22. package/commands/update/index.ts +7 -0
  23. package/commands/update-completed-count/UpdateCompletedCountCommand.ts +208 -0
  24. package/commands/update-completed-count/index.ts +5 -0
  25. package/dist/markdown/index.cjs +6 -9
  26. package/dist/markdown/index.js +6 -9
  27. package/dist/vitepress/config/index.cjs +214 -58
  28. package/dist/vitepress/config/index.js +208 -52
  29. package/markdown/components.ts +86 -0
  30. package/markdown/index.ts +17 -0
  31. package/markdown/noteFormatter.test.ts +44 -0
  32. package/markdown/noteFormatter.ts +237 -0
  33. package/package.json +7 -3
  34. package/vitepress/components/BilibiliOutsidePlayer/BilibiliOutsidePlayer.vue +9 -18
  35. package/vitepress/components/EnWordList/EnWordList.vue +16 -662
  36. package/vitepress/components/Footprints/Footprints.vue +15 -537
  37. package/vitepress/components/Mermaid/Mermaid.vue +13 -588
  38. package/vitepress/components/MindmapPreview/MindmapPreview.vue +12 -434
  39. package/vitepress/components/MindmapPreview/markdown.ts +1 -1
  40. package/vitepress/components/NotesTable/NotesTable.vue +11 -130
  41. package/vitepress/configs/markdown.config.ts +170 -26
  42. package/vitepress/theme/index.ts +9 -13
  43. package/vitepress/theme/styles/base.scss +15 -0
  44. package/workspace/atomic.ts +113 -0
  45. package/workspace/errors.ts +27 -0
  46. package/workspace/index.ts +40 -0
  47. package/workspace/mutationQueue.ts +28 -0
  48. package/workspace/paths.ts +64 -0
  49. package/workspace/reconcile.test.ts +300 -0
  50. package/workspace/reconcile.ts +95 -0
  51. package/workspace/scanner.ts +292 -0
  52. package/workspace/types.ts +224 -0
  53. package/workspace/workspace.test.ts +333 -0
  54. package/workspace/workspace.ts +1020 -0
  55. package/vitepress/components/EnWordList/RightClickMenu.vue +0 -93
@@ -0,0 +1,224 @@
1
+ import type { TNotesConfig } from '../types/config'
2
+ import type { NoteConfig } from '../types/note'
3
+ import type { TocTreeNode, TocSidebarItem } from '../utils/tocHelpers'
4
+
5
+ export type WorkspaceDiagnosticSeverity = 'error' | 'warning' | 'info'
6
+
7
+ export interface WorkspaceDiagnostic {
8
+ code: string
9
+ message: string
10
+ severity: WorkspaceDiagnosticSeverity
11
+ path?: string
12
+ }
13
+
14
+ export type WorkspaceHealth =
15
+ | { status: 'ready'; diagnostics: WorkspaceDiagnostic[] }
16
+ | { status: 'invalid'; diagnostics: WorkspaceDiagnostic[] }
17
+ | { status: 'future-schema'; diagnostics: WorkspaceDiagnostic[] }
18
+
19
+ /**
20
+ * Core only edits known fields, but it must retain fields introduced by a
21
+ * knowledge base or by a newer Core release.
22
+ */
23
+ export type WorkspaceNoteConfig = NoteConfig & Record<string, unknown>
24
+ export type WorkspaceKnowledgeBaseConfig = TNotesConfig &
25
+ Record<string, unknown>
26
+
27
+ export interface WorkspaceNoteSummary {
28
+ uuid: string
29
+ index: string
30
+ title: string
31
+ dirName: string
32
+ directoryPath: string
33
+ readmePath: string
34
+ configPath: string
35
+ config: WorkspaceNoteConfig
36
+ revision: string
37
+ }
38
+
39
+ export interface KnowledgeBaseSnapshot {
40
+ id: string
41
+ rootPath: string
42
+ config: WorkspaceKnowledgeBaseConfig | null
43
+ health: WorkspaceHealth
44
+ toc: TocTreeNode[]
45
+ sidebar: TocSidebarItem[]
46
+ notes: WorkspaceNoteSummary[]
47
+ revision: string
48
+ }
49
+
50
+ export interface NoteDocument extends WorkspaceNoteSummary {
51
+ content: string
52
+ }
53
+
54
+ export interface ChangedFile {
55
+ path: string
56
+ kind: 'created' | 'updated' | 'deleted' | 'renamed' | 'trashed'
57
+ previousPath?: string
58
+ }
59
+
60
+ export interface MutationResult<T> {
61
+ value: T
62
+ changedFiles: ChangedFile[]
63
+ snapshotRevision: string
64
+ }
65
+
66
+ export interface WorkspaceLogger {
67
+ debug?(message: string, details?: unknown): void
68
+ info?(message: string, details?: unknown): void
69
+ warn?(message: string, details?: unknown): void
70
+ error?(message: string, details?: unknown): void
71
+ }
72
+
73
+ export interface CreateWorkspaceOptions {
74
+ rootPath: string
75
+ logger?: WorkspaceLogger
76
+ format?: {
77
+ prettier?: boolean
78
+ }
79
+ }
80
+
81
+ export interface SaveNoteInput {
82
+ noteUuid: string
83
+ content: string
84
+ expectedRevision: string
85
+ prettier?: boolean
86
+ }
87
+
88
+ export type NotePlacement =
89
+ | { type: 'root'; placement?: 'start' | 'end' }
90
+ | {
91
+ type: 'note'
92
+ targetNoteUuid: string
93
+ placement: 'before' | 'after' | 'inside'
94
+ }
95
+ | {
96
+ type: 'folder'
97
+ folderPath: string[]
98
+ placement: 'before' | 'after' | 'inside'
99
+ }
100
+
101
+ export interface CreateNoteInput {
102
+ title: string
103
+ placement?: NotePlacement
104
+ config?: Partial<Pick<WorkspaceNoteConfig, 'description' | 'enableDiscussions'>>
105
+ expectedSnapshotRevision?: string
106
+ }
107
+
108
+ export interface RenameNoteInput {
109
+ noteUuid: string
110
+ title: string
111
+ expectedRevision: string
112
+ }
113
+
114
+ export interface UpdateNoteConfigInput {
115
+ noteUuid: string
116
+ updates: Partial<
117
+ Pick<
118
+ WorkspaceNoteConfig,
119
+ 'done' | 'description' | 'enableDiscussions'
120
+ >
121
+ >
122
+ expectedRevision: string
123
+ }
124
+
125
+ export type TocEntryRef =
126
+ | { type: 'note'; noteUuid: string }
127
+ | { type: 'folder'; folderPath: string[] }
128
+ | { type: 'line'; tocLineIndex: number }
129
+
130
+ export interface MoveTocEntryInput {
131
+ source: TocEntryRef
132
+ target: TocEntryRef
133
+ placement: 'before' | 'after' | 'inside'
134
+ expectedSnapshotRevision: string
135
+ }
136
+
137
+ export interface CreateTocGroupInput {
138
+ title: string
139
+ placement?: NotePlacement
140
+ expectedSnapshotRevision: string
141
+ }
142
+
143
+ export interface RenameTocGroupInput {
144
+ folderPath: string[]
145
+ title: string
146
+ expectedSnapshotRevision: string
147
+ }
148
+
149
+ export interface DeletePreviewItem {
150
+ noteUuid: string
151
+ index: string
152
+ title: string
153
+ directoryPath: string
154
+ }
155
+
156
+ export interface DeleteTocEntryPreview {
157
+ entry: TocEntryRef
158
+ notes: DeletePreviewItem[]
159
+ filePaths: string[]
160
+ directoryPaths: string[]
161
+ snapshotRevision: string
162
+ }
163
+
164
+ export interface DeleteTocEntryInput {
165
+ entry: TocEntryRef
166
+ expectedSnapshotRevision: string
167
+ }
168
+
169
+ export interface WriteAttachmentInput {
170
+ noteUuid: string
171
+ fileName: string
172
+ data: Uint8Array
173
+ }
174
+
175
+ export interface AttachmentResult {
176
+ absolutePath: string
177
+ markdownPath: string
178
+ }
179
+
180
+ export interface TNotesWorkspace {
181
+ inspect(): Promise<KnowledgeBaseSnapshot>
182
+ refresh(): Promise<KnowledgeBaseSnapshot>
183
+ reconcileTocCompletion(): Promise<MutationResult<KnowledgeBaseSnapshot>>
184
+
185
+ notes: {
186
+ read(noteUuid: string): Promise<NoteDocument>
187
+ save(input: SaveNoteInput): Promise<MutationResult<NoteDocument>>
188
+ create(input: CreateNoteInput): Promise<MutationResult<NoteDocument>>
189
+ rename(input: RenameNoteInput): Promise<MutationResult<NoteDocument>>
190
+ updateConfig(
191
+ input: UpdateNoteConfigInput,
192
+ ): Promise<MutationResult<NoteDocument>>
193
+ }
194
+
195
+ toc: {
196
+ move(input: MoveTocEntryInput): Promise<MutationResult<KnowledgeBaseSnapshot>>
197
+ createGroup(
198
+ input: CreateTocGroupInput,
199
+ ): Promise<MutationResult<KnowledgeBaseSnapshot>>
200
+ renameGroup(
201
+ input: RenameTocGroupInput,
202
+ ): Promise<MutationResult<KnowledgeBaseSnapshot>>
203
+ previewDelete(entry: TocEntryRef): Promise<DeleteTocEntryPreview>
204
+ deleteEntry(
205
+ input: DeleteTocEntryInput,
206
+ ): Promise<MutationResult<KnowledgeBaseSnapshot>>
207
+ setDone(input: UpdateNoteConfigInput): Promise<MutationResult<NoteDocument>>
208
+ /**
209
+ * Align TOC.md + sidebar.json with the filesystem truth (files-first).
210
+ * Valid notes missing from the TOC are appended (root level, by index);
211
+ * note dirs with a missing/invalid config are soft-deleted to
212
+ * notes/.trash/. Idempotent: no changes -> changedFiles = [].
213
+ */
214
+ reconcileFromFiles(): Promise<MutationResult<KnowledgeBaseSnapshot>>
215
+ }
216
+
217
+ attachments: {
218
+ writeLocal(
219
+ input: WriteAttachmentInput,
220
+ ): Promise<MutationResult<AttachmentResult>>
221
+ }
222
+
223
+ dispose(): Promise<void>
224
+ }
@@ -0,0 +1,333 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { createWorkspace, WorkspaceError } from './index'
7
+
8
+ const temporaryDirectories: string[] = []
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(
12
+ temporaryDirectories.splice(0).map((directory) =>
13
+ fs.rm(directory, { recursive: true, force: true }),
14
+ ),
15
+ )
16
+ })
17
+
18
+ function knowledgeBaseConfig(id: string, repoName: string) {
19
+ return {
20
+ id,
21
+ author: 'tnotesjs',
22
+ repoName,
23
+ keywords: [repoName],
24
+ sidebarShowNoteId: false,
25
+ ignore_dirs: [],
26
+ socialLinks: [],
27
+ menuItems: [],
28
+ root_item: {
29
+ title: repoName,
30
+ completed_notes_count: {},
31
+ details: repoName,
32
+ link: '/',
33
+ },
34
+ }
35
+ }
36
+
37
+ function noteConfig(id: string, done = false) {
38
+ return {
39
+ bilibili: [],
40
+ tnotes: [],
41
+ yuque: [],
42
+ done,
43
+ enableDiscussions: false,
44
+ description: '',
45
+ id,
46
+ customField: 'preserved',
47
+ }
48
+ }
49
+
50
+ async function createFixture(input?: {
51
+ repoName?: string
52
+ knowledgeBaseId?: string
53
+ notes?: Array<{
54
+ index: string
55
+ title: string
56
+ id: string
57
+ done?: boolean
58
+ content?: string
59
+ }>
60
+ toc?: string
61
+ }) {
62
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'tnotes-core-workspace-'))
63
+ temporaryDirectories.push(root)
64
+ const repoName = input?.repoName ?? 'TNotes.fixture'
65
+ const notes =
66
+ input?.notes ??
67
+ [
68
+ {
69
+ index: '0001',
70
+ title: 'Alpha',
71
+ id: 'note-alpha',
72
+ done: false,
73
+ },
74
+ ]
75
+ await fs.mkdir(path.join(root, 'notes'))
76
+ await fs.writeFile(
77
+ path.join(root, '.tnotes.json'),
78
+ JSON.stringify(
79
+ knowledgeBaseConfig(input?.knowledgeBaseId ?? 'kb-fixture', repoName),
80
+ null,
81
+ 2,
82
+ ),
83
+ )
84
+ await fs.writeFile(path.join(root, 'README.md'), 'legacy root readme\n')
85
+
86
+ for (const note of notes) {
87
+ const noteDirectory = path.join(root, 'notes', `${note.index}. ${note.title}`)
88
+ await fs.mkdir(noteDirectory)
89
+ await fs.writeFile(
90
+ path.join(noteDirectory, '.tnotes.json'),
91
+ `${JSON.stringify(noteConfig(note.id, note.done), null, 2)}\n`,
92
+ )
93
+ await fs.writeFile(
94
+ path.join(noteDirectory, 'README.md'),
95
+ note.content ??
96
+ `# stale title\n\n<!-- region:toc -->\n\n<!-- endregion:toc -->\n\n## Intro\n`,
97
+ )
98
+ }
99
+
100
+ const toc =
101
+ input?.toc ??
102
+ `${notes.map((note) => `- [${note.done ? 'x' : ' '}] ${note.index}. ${note.title}`).join('\n')}\n`
103
+ await fs.writeFile(path.join(root, 'TOC.md'), toc)
104
+ await fs.writeFile(path.join(root, 'sidebar.json'), '[]')
105
+ return root
106
+ }
107
+
108
+ describe('path-injected workspace', () => {
109
+ it('keeps two knowledge bases isolated in the same process', async () => {
110
+ const firstRoot = await createFixture({
111
+ repoName: 'TNotes.first',
112
+ knowledgeBaseId: 'kb-first',
113
+ notes: [{ index: '0001', title: 'First', id: 'first-note' }],
114
+ })
115
+ const secondRoot = await createFixture({
116
+ repoName: 'TNotes.second',
117
+ knowledgeBaseId: 'kb-second',
118
+ notes: [{ index: '0001', title: 'Second', id: 'second-note' }],
119
+ })
120
+ const first = createWorkspace({ rootPath: firstRoot })
121
+ const second = createWorkspace({ rootPath: secondRoot })
122
+
123
+ const [firstSnapshot, secondSnapshot] = await Promise.all([
124
+ first.inspect(),
125
+ second.inspect(),
126
+ ])
127
+ expect(firstSnapshot.id).toBe('kb-first')
128
+ expect(secondSnapshot.id).toBe('kb-second')
129
+ expect(firstSnapshot.notes[0].title).toBe('First')
130
+ expect(secondSnapshot.notes[0].title).toBe('Second')
131
+
132
+ const firstDocument = await first.notes.read('first-note')
133
+ const secondDocument = await second.notes.read('second-note')
134
+ expect(firstDocument.readmePath.startsWith(firstRoot)).toBe(true)
135
+ expect(secondDocument.readmePath.startsWith(secondRoot)).toBe(true)
136
+
137
+ await first.dispose()
138
+ await second.dispose()
139
+ })
140
+
141
+ it('reports an invalid workspace without exiting the process', async () => {
142
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'tnotes-invalid-'))
143
+ temporaryDirectories.push(root)
144
+ const workspace = createWorkspace({ rootPath: root })
145
+ const snapshot = await workspace.inspect()
146
+
147
+ expect(snapshot.health.status).toBe('invalid')
148
+ expect(snapshot.health.diagnostics.map((item) => item.code)).toEqual(
149
+ expect.arrayContaining([
150
+ 'CONFIG_MISSING',
151
+ 'TOC_MISSING',
152
+ 'NOTES_DIRECTORY_MISSING',
153
+ ]),
154
+ )
155
+ await expect(
156
+ workspace.notes.create({ title: 'Blocked' }),
157
+ ).rejects.toMatchObject({ code: 'WORKSPACE_INVALID' })
158
+ })
159
+
160
+ it('formats and saves a note without changing the root README', async () => {
161
+ const root = await createFixture()
162
+ const workspace = createWorkspace({ rootPath: root })
163
+ const before = await workspace.notes.read('note-alpha')
164
+ const rootReadmeBefore = await fs.readFile(path.join(root, 'README.md'), 'utf8')
165
+
166
+ const result = await workspace.notes.save({
167
+ noteUuid: 'note-alpha',
168
+ expectedRevision: before.revision,
169
+ content: `# user title\n\n## Hello\n\n### Child\n\n#### 9.8. Plain\n\n\`\`\`md\n## Not a heading\n\`\`\`\n`,
170
+ })
171
+
172
+ expect(result.value.content).toContain(
173
+ '# [0001. Alpha](https://github.com/tnotesjs/TNotes.fixture/tree/main/notes/0001.%20Alpha)',
174
+ )
175
+ expect(result.value.content).toContain('## 1. Hello')
176
+ expect(result.value.content).toContain('### 1.1. Child')
177
+ expect(result.value.content).toContain('#### Plain')
178
+ expect(result.value.content).toContain('```md\n## Not a heading\n```')
179
+ expect(result.value.content).toContain('<!-- region:toc -->')
180
+ expect(result.value.content).toContain('- [1. Hello](#1-hello)')
181
+ expect(await fs.readFile(path.join(root, 'README.md'), 'utf8')).toBe(
182
+ rootReadmeBefore,
183
+ )
184
+
185
+ await expect(
186
+ workspace.notes.save({
187
+ noteUuid: 'note-alpha',
188
+ expectedRevision: before.revision,
189
+ content: result.value.content,
190
+ }),
191
+ ).rejects.toMatchObject({ code: 'REVISION_CONFLICT' })
192
+ })
193
+
194
+ it('uses the smallest free index and updates only TOC/sidebar', async () => {
195
+ const root = await createFixture({
196
+ notes: [
197
+ { index: '0001', title: 'One', id: 'one' },
198
+ { index: '0003', title: 'Three', id: 'three' },
199
+ ],
200
+ })
201
+ const workspace = createWorkspace({ rootPath: root })
202
+ const rootReadmeBefore = await fs.readFile(path.join(root, 'README.md'), 'utf8')
203
+ const created = await workspace.notes.create({
204
+ title: 'Two',
205
+ placement: {
206
+ type: 'note',
207
+ targetNoteUuid: 'three',
208
+ placement: 'before',
209
+ },
210
+ })
211
+
212
+ expect(created.value.index).toBe('0002')
213
+ expect(created.value.config).not.toHaveProperty('category')
214
+ const toc = await fs.readFile(path.join(root, 'TOC.md'), 'utf8')
215
+ expect(toc.indexOf('0002. Two')).toBeLessThan(toc.indexOf('0003. Three'))
216
+ expect(await fs.readFile(path.join(root, 'README.md'), 'utf8')).toBe(
217
+ rootReadmeBefore,
218
+ )
219
+ })
220
+
221
+ it('renames a note while preserving its UUID and unknown config fields', async () => {
222
+ const root = await createFixture()
223
+ const workspace = createWorkspace({ rootPath: root })
224
+ const before = await workspace.notes.read('note-alpha')
225
+ const rootReadmeBefore = await fs.readFile(path.join(root, 'README.md'), 'utf8')
226
+ const renamed = await workspace.notes.rename({
227
+ noteUuid: before.uuid,
228
+ title: 'Renamed',
229
+ expectedRevision: before.revision,
230
+ })
231
+
232
+ expect(renamed.value.uuid).toBe('note-alpha')
233
+ expect(renamed.value.dirName).toBe('0001. Renamed')
234
+ expect(renamed.value.config.customField).toBe('preserved')
235
+ await expect(fs.access(path.join(root, 'notes', '0001. Alpha'))).rejects.toThrow()
236
+ expect(await fs.readFile(path.join(root, 'TOC.md'), 'utf8')).toContain(
237
+ '0001. Renamed',
238
+ )
239
+ expect(await fs.readFile(path.join(root, 'README.md'), 'utf8')).toBe(
240
+ rootReadmeBefore,
241
+ )
242
+ })
243
+
244
+ it('syncs an externally changed TOC checkbox back to note config', async () => {
245
+ const root = await createFixture()
246
+ const workspace = createWorkspace({ rootPath: root })
247
+ await fs.writeFile(path.join(root, 'TOC.md'), '- [x] 0001. Alpha\n')
248
+ const result = await workspace.reconcileTocCompletion()
249
+
250
+ expect(result.value.notes[0].config.done).toBe(true)
251
+ const config = JSON.parse(
252
+ await fs.readFile(
253
+ path.join(root, 'notes', '0001. Alpha', '.tnotes.json'),
254
+ 'utf8',
255
+ ),
256
+ )
257
+ expect(config.done).toBe(true)
258
+ expect(config.customField).toBe('preserved')
259
+ })
260
+
261
+ it('previews and permanently deletes a TOC subtree', async () => {
262
+ const root = await createFixture({
263
+ notes: [
264
+ { index: '0001', title: 'Parent', id: 'parent' },
265
+ { index: '0002', title: 'Child', id: 'child' },
266
+ { index: '0003', title: 'Keep', id: 'keep' },
267
+ ],
268
+ toc: '- Group\n - [ ] 0001. Parent\n - [ ] 0002. Child\n- [ ] 0003. Keep\n',
269
+ })
270
+ const workspace = createWorkspace({ rootPath: root })
271
+ const preview = await workspace.toc.previewDelete({
272
+ type: 'folder',
273
+ folderPath: ['Group'],
274
+ })
275
+ expect(preview.notes.map((note) => note.noteUuid)).toEqual([
276
+ 'parent',
277
+ 'child',
278
+ ])
279
+ expect(preview.filePaths).toHaveLength(4)
280
+
281
+ const deleted = await workspace.toc.deleteEntry({
282
+ entry: { type: 'folder', folderPath: ['Group'] },
283
+ expectedSnapshotRevision: preview.snapshotRevision,
284
+ })
285
+ expect(deleted.value.notes.map((note) => note.uuid)).toEqual(['keep'])
286
+ expect(await fs.readFile(path.join(root, 'TOC.md'), 'utf8')).toBe(
287
+ '- [ ] 0003. Keep\n',
288
+ )
289
+ })
290
+
291
+ it('writes local attachments with collision-safe names', async () => {
292
+ const root = await createFixture()
293
+ const workspace = createWorkspace({ rootPath: root })
294
+ const first = await workspace.attachments.writeLocal({
295
+ noteUuid: 'note-alpha',
296
+ fileName: 'image.png',
297
+ data: new Uint8Array([1, 2, 3]),
298
+ })
299
+ const second = await workspace.attachments.writeLocal({
300
+ noteUuid: 'note-alpha',
301
+ fileName: 'image.png',
302
+ data: new Uint8Array([4, 5, 6]),
303
+ })
304
+
305
+ expect(first.value.markdownPath).toBe('./assets/image.png')
306
+ expect(second.value.markdownPath).toBe('./assets/image-1.png')
307
+ })
308
+
309
+ it('becomes read-only for a future schema', async () => {
310
+ const root = await createFixture()
311
+ const configPath = path.join(root, '.tnotes.json')
312
+ const config = JSON.parse(await fs.readFile(configPath, 'utf8'))
313
+ config.schemaVersion = 999
314
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2))
315
+ const workspace = createWorkspace({ rootPath: root })
316
+
317
+ expect((await workspace.inspect()).health.status).toBe('future-schema')
318
+ await expect(workspace.notes.read('note-alpha')).resolves.toMatchObject({
319
+ uuid: 'note-alpha',
320
+ })
321
+ const document = await workspace.notes.read('note-alpha')
322
+ await expect(
323
+ workspace.notes.save({
324
+ noteUuid: document.uuid,
325
+ expectedRevision: document.revision,
326
+ content: document.content,
327
+ }),
328
+ ).rejects.toMatchObject({
329
+ code: 'WORKSPACE_READ_ONLY',
330
+ })
331
+ expect(WorkspaceError).toBeTypeOf('function')
332
+ })
333
+ })