|
|
@@ -1,7 +1,8 @@
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
import z from '@deepseek-ai/schemastery'
|
|
|
-import { SettingsProvider, SettingsConflictError, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
|
|
|
+import { SettingsProvider, SettingsConflictError, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
|
|
|
+import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
|
|
|
import { MemorySettings } from './memory.ts'
|
|
|
|
|
|
/** A provider implementing only the three primitives: the Service Definition owns initialization. */
|
|
|
@@ -75,20 +76,17 @@ function recordUpdates(ctx: Context) {
|
|
|
return events
|
|
|
}
|
|
|
|
|
|
-describe('settingsNamespace', () => {
|
|
|
- it('brands lowercase kebab-case names', () => {
|
|
|
- expect(settingsNamespace('ui-theme')).toBe('ui-theme')
|
|
|
- })
|
|
|
-
|
|
|
- it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => {
|
|
|
- expect(() => settingsNamespace(value)).toThrow(TypeError)
|
|
|
+describe('settings namespace validation', () => {
|
|
|
+ it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j at the service', async (value) => {
|
|
|
+ const { ctx } = await boot()
|
|
|
+ expect(() => ctx.settings.register(value, ThemeSchema)).toThrow(TypeError)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe('registration', () => {
|
|
|
it('resolves schema defaults, then composition base, then the user layer', async () => {
|
|
|
const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema, {
|
|
|
base: { fontSize: 16 },
|
|
|
})
|
|
|
// theme: user layer wins; fontSize: base wins over the schema default.
|
|
|
@@ -97,7 +95,7 @@ describe('registration', () => {
|
|
|
|
|
|
it('refuses a write its owner could not act on, and keeps the last good value for a stored one', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- const ns = settingsNamespace('ui-theme')
|
|
|
+ const ns = 'ui-theme'
|
|
|
// A constraint the schema cannot express: this owner cannot serve a size
|
|
|
// it considers unreadable, whatever the schema admits.
|
|
|
const scope = ctx.settings.register(ns, ThemeSchema, {
|
|
|
@@ -126,7 +124,7 @@ describe('registration', () => {
|
|
|
// owner cannot serve therefore refuses the registration rather than
|
|
|
// mounting an owner over configuration it rejects.
|
|
|
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 4 } } })
|
|
|
- expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
|
|
+ expect(() => ctx.settings.register('ui-theme', ThemeSchema, {
|
|
|
validate: (value) => {
|
|
|
if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`)
|
|
|
},
|
|
|
@@ -135,26 +133,26 @@ describe('registration', () => {
|
|
|
|
|
|
it('rejects a duplicate namespace loud', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
- expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
|
|
|
+ ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
+ expect(() => ctx.settings.register('ui-theme', ThemeSchema))
|
|
|
.toThrow(/already registered/)
|
|
|
})
|
|
|
|
|
|
it('fails registration when the stored section is invalid for the schema', async () => {
|
|
|
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } })
|
|
|
- expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow()
|
|
|
+ expect(() => ctx.settings.register('ui-theme', ThemeSchema)).toThrow()
|
|
|
})
|
|
|
|
|
|
it('fails registration when the stored section is not an object', async () => {
|
|
|
const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } })
|
|
|
- expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
|
|
|
+ expect(() => ctx.settings.register('ui-theme', ThemeSchema))
|
|
|
.toThrow(/must be an object/)
|
|
|
})
|
|
|
|
|
|
it('describes registered namespaces with schema JSON, value, and applies', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
- ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' })
|
|
|
+ ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
+ ctx.settings.register('workspace', NestedSchema, { applies: 'restart' })
|
|
|
const descriptors = ctx.settings.describe()
|
|
|
expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([
|
|
|
['ui-theme', 'live'],
|
|
|
@@ -169,12 +167,12 @@ describe('registration', () => {
|
|
|
|
|
|
it('reads undefined for an unregistered namespace', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined()
|
|
|
+ expect(ctx.settings.get('missing')).toBeUndefined()
|
|
|
})
|
|
|
|
|
|
it('hands out frozen resolved values', async () => {
|
|
|
const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } })
|
|
|
- const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
|
|
+ const scope = ctx.settings.register('workspace', NestedSchema)
|
|
|
const value = scope.get()
|
|
|
expect(Object.isFrozen(value)).toBe(true)
|
|
|
expect(Object.isFrozen(value.retry)).toBe(true)
|
|
|
@@ -188,22 +186,22 @@ describe('registration', () => {
|
|
|
const fiber = ctx.plugin({
|
|
|
inject: ['settings'],
|
|
|
apply: (child: Context) => {
|
|
|
- scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ scope = child.settings.register('ui-theme', ThemeSchema)
|
|
|
scope.watch((next) => { seen.push(next) })
|
|
|
},
|
|
|
})
|
|
|
await fiber
|
|
|
- expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 })
|
|
|
+ expect(ctx.settings.get('ui-theme')).toEqual({ theme: 'dark', fontSize: 14 })
|
|
|
|
|
|
await fiber.dispose()
|
|
|
- expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined()
|
|
|
+ expect(ctx.settings.get('ui-theme')).toBeUndefined()
|
|
|
expect(ctx.settings.describe()).toEqual([])
|
|
|
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
|
|
expect(seen).toEqual([])
|
|
|
|
|
|
// The namespace is free again, and re-registration resolves the user layer
|
|
|
// that kept living in storage while nobody owned the namespace.
|
|
|
- const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const again = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
expect(again.get()).toEqual({ theme: 'light', fontSize: 14 })
|
|
|
})
|
|
|
})
|
|
|
@@ -211,7 +209,7 @@ describe('registration', () => {
|
|
|
describe('update', () => {
|
|
|
it('persists the merged user section without baking in the base layer', async () => {
|
|
|
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema, {
|
|
|
base: { fontSize: 16 },
|
|
|
})
|
|
|
await scope.update({ theme: 'dark' })
|
|
|
@@ -225,7 +223,7 @@ describe('update', () => {
|
|
|
const { ctx, provider } = await boot({
|
|
|
doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } },
|
|
|
})
|
|
|
- const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
|
|
+ const scope = ctx.settings.register('workspace', NestedSchema)
|
|
|
await scope.update({ retry: { attempts: 7 }, tags: ['c'] })
|
|
|
expect(provider.persisted[0]!.section).toEqual({
|
|
|
retry: { attempts: 7, delayMs: 300 },
|
|
|
@@ -237,7 +235,7 @@ describe('update', () => {
|
|
|
it('commits, notifies watchers, and emits with source update', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
const events = recordUpdates(ctx)
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
const watcher = vi.fn()
|
|
|
scope.watch(watcher)
|
|
|
await scope.update({ theme: 'light' })
|
|
|
@@ -256,7 +254,7 @@ describe('update', () => {
|
|
|
it('rejects an invalid patch before persisting anything', async () => {
|
|
|
const { ctx, provider } = await boot()
|
|
|
const events = recordUpdates(ctx)
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
await expect(scope.update({ fontSize: 'big' })).rejects.toThrow()
|
|
|
expect(provider.persisted).toEqual([])
|
|
|
expect(events).toEqual([])
|
|
|
@@ -268,7 +266,7 @@ describe('update', () => {
|
|
|
|
|
|
it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => {
|
|
|
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
await scope.update({ theme: undefined, fontSize: 18 })
|
|
|
expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 })
|
|
|
expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 })
|
|
|
@@ -276,7 +274,7 @@ describe('update', () => {
|
|
|
|
|
|
it('rejects a non-object patch', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
await expect(scope.update([1])).rejects.toThrow(TypeError)
|
|
|
await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError)
|
|
|
await expect(scope.replace([1])).rejects.toThrow(/replace for "ui-theme"/)
|
|
|
@@ -284,7 +282,7 @@ describe('update', () => {
|
|
|
|
|
|
it('accepts a null-prototype patch object', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number }
|
|
|
patch.fontSize = 18
|
|
|
await scope.update(patch)
|
|
|
@@ -293,13 +291,13 @@ describe('update', () => {
|
|
|
|
|
|
it('rejects an unregistered namespace', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- await expect(ctx.settings.update(settingsNamespace('missing'), {}))
|
|
|
+ await expect(ctx.settings.update('missing', {}))
|
|
|
.rejects.toThrow(/not registered/)
|
|
|
})
|
|
|
|
|
|
it('rejects on a read-only provider before reaching persist', async () => {
|
|
|
const { ctx, provider } = await boot({ writable: false })
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/)
|
|
|
expect(provider.persisted).toEqual([])
|
|
|
})
|
|
|
@@ -325,14 +323,14 @@ describe('review regressions', () => {
|
|
|
ctx.on('settings/updated', () => {
|
|
|
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
|
|
|
})
|
|
|
- ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) })
|
|
|
.toThrow(/forged relation/)
|
|
|
})
|
|
|
|
|
|
it('serializes concurrent updates so neither patch is lost', async () => {
|
|
|
const { ctx, provider } = await boot({ persistDelayMs: 10 })
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
await Promise.all([
|
|
|
scope.update({ theme: 'light' }),
|
|
|
scope.update({ fontSize: 20 }),
|
|
|
@@ -346,7 +344,7 @@ describe('review regressions', () => {
|
|
|
ctx.on('settings/updated', () => {
|
|
|
throw new Error('listener boom')
|
|
|
})
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }).not.toThrow()
|
|
|
expect(scope.get().theme).toBe('light')
|
|
|
provider.pushExternal({ 'ui-theme': { theme: 'dark' } })
|
|
|
@@ -355,7 +353,7 @@ describe('review regressions', () => {
|
|
|
|
|
|
it('contains an async watcher rejection', async () => {
|
|
|
const { ctx, provider } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
scope.watch(async () => {
|
|
|
throw new Error('async watcher boom')
|
|
|
})
|
|
|
@@ -369,13 +367,13 @@ describe('review regressions', () => {
|
|
|
it('loads the provider document through the base init without provider boilerplate', async () => {
|
|
|
const ctx = new Context()
|
|
|
await ctx.plugin(BareProvider, { doc: { 'ui-theme': { fontSize: 7 } } })
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 7 })
|
|
|
})
|
|
|
|
|
|
it('replaces the user section wholesale so overrides can be removed', async () => {
|
|
|
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light', fontSize: 20 } } })
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema, {
|
|
|
base: { fontSize: 16 },
|
|
|
})
|
|
|
await scope.replace({ theme: 'light' })
|
|
|
@@ -396,7 +394,7 @@ describe('second review regressions', () => {
|
|
|
})
|
|
|
const second = vi.fn()
|
|
|
ctx.on('settings/updated', second)
|
|
|
- ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
|
|
expect(second).toHaveBeenCalledTimes(1)
|
|
|
})
|
|
|
@@ -407,7 +405,7 @@ describe('second review regressions', () => {
|
|
|
const fiber = ctx.plugin({
|
|
|
inject: ['settings'],
|
|
|
apply: (child: Context) => {
|
|
|
- scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ scope = child.settings.register('ui-theme', ThemeSchema)
|
|
|
},
|
|
|
})
|
|
|
await fiber
|
|
|
@@ -423,7 +421,7 @@ describe('second review regressions', () => {
|
|
|
const fiber = ctx.plugin({
|
|
|
inject: ['settings'],
|
|
|
apply: (child: Context) => {
|
|
|
- scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ scope = child.settings.register('ui-theme', ThemeSchema)
|
|
|
scope.watch(watcher)
|
|
|
},
|
|
|
})
|
|
|
@@ -443,7 +441,7 @@ describe('second review regressions', () => {
|
|
|
it('drains in-flight writes at service dispose and rejects later ones', async () => {
|
|
|
const { ctx, provider, fiber } = await boot({ persistDelayMs: 20 })
|
|
|
const service = ctx.settings
|
|
|
- const scope = service.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = service.register('ui-theme', ThemeSchema)
|
|
|
const pending = scope.update({ theme: 'light' })
|
|
|
await new Promise(resolve => setTimeout(resolve, 5))
|
|
|
await fiber.dispose()
|
|
|
@@ -452,7 +450,7 @@ describe('second review regressions', () => {
|
|
|
const persistedAtDispose = provider.persisted.length
|
|
|
expect(persistedAtDispose).toBe(1)
|
|
|
// …and afterwards nothing writes and new writes reject.
|
|
|
- await expect(service.update(settingsNamespace('ui-theme'), { theme: 'dark' }))
|
|
|
+ await expect(service.update('ui-theme', { theme: 'dark' }))
|
|
|
.rejects.toThrow(/disposed|not registered/)
|
|
|
await new Promise(resolve => setTimeout(resolve, 40))
|
|
|
expect(provider.persisted.length).toBe(persistedAtDispose)
|
|
|
@@ -460,7 +458,7 @@ describe('second review regressions', () => {
|
|
|
|
|
|
it('serializes invocations of one async watcher in commit order', async () => {
|
|
|
const { ctx, provider } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
const applied: number[] = []
|
|
|
let firstCall = true
|
|
|
scope.watch(async (next) => {
|
|
|
@@ -481,14 +479,14 @@ describe('second review regressions', () => {
|
|
|
|
|
|
it('rejects a function value as not JSON-compatible', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
await expect(scope.update({ theme: () => 'dark' }))
|
|
|
.rejects.toThrow(/JSON-compatible.*function at \$\.theme/)
|
|
|
})
|
|
|
|
|
|
it('rejects a write still queued when the service disposes', async () => {
|
|
|
const { ctx, fiber } = await boot({ persistDelayMs: 20 })
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
const first = scope.update({ theme: 'light' })
|
|
|
const second = scope.update({ fontSize: 20 })
|
|
|
await new Promise(resolve => setTimeout(resolve, 5))
|
|
|
@@ -503,7 +501,7 @@ describe('second review regressions', () => {
|
|
|
const fiber = ctx.plugin({
|
|
|
inject: ['settings'],
|
|
|
apply: (child: Context) => {
|
|
|
- scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ scope = child.settings.register('ui-theme', ThemeSchema)
|
|
|
},
|
|
|
})
|
|
|
await fiber
|
|
|
@@ -517,7 +515,7 @@ describe('second review regressions', () => {
|
|
|
|
|
|
it('snapshots the patch at call time so caller mutation cannot leak in', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
const patch = { fontSize: 18 }
|
|
|
const pending = scope.update(patch)
|
|
|
patch.fontSize = 99
|
|
|
@@ -530,7 +528,7 @@ describe('publish', () => {
|
|
|
it('notifies watchers of an external change with source provider', async () => {
|
|
|
const { ctx, provider } = await boot()
|
|
|
const events = recordUpdates(ctx)
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
const watcher = vi.fn()
|
|
|
scope.watch(watcher)
|
|
|
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
|
|
@@ -546,7 +544,7 @@ describe('publish', () => {
|
|
|
it('stays silent when the resolved value is deep-equal', async () => {
|
|
|
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
|
|
const events = recordUpdates(ctx)
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
const watcher = vi.fn()
|
|
|
scope.watch(watcher)
|
|
|
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
|
|
@@ -557,8 +555,8 @@ describe('publish', () => {
|
|
|
it('keeps the last good value for an invalid section while other namespaces commit', async () => {
|
|
|
const { ctx, provider } = await boot()
|
|
|
const events = recordUpdates(ctx)
|
|
|
- const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
- const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
|
|
+ const theme = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
+ const workspace = ctx.settings.register('workspace', NestedSchema)
|
|
|
provider.pushExternal({
|
|
|
'ui-theme': { fontSize: 'broken' },
|
|
|
workspace: { retry: { attempts: 9 } },
|
|
|
@@ -570,7 +568,7 @@ describe('publish', () => {
|
|
|
|
|
|
it('recovers from a bad section once storage turns valid again', async () => {
|
|
|
const { ctx, provider } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } })
|
|
|
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
|
|
provider.pushExternal({ 'ui-theme': { fontSize: 18 } })
|
|
|
@@ -581,7 +579,7 @@ describe('publish', () => {
|
|
|
describe('third review regressions', () => {
|
|
|
it('skips a queued watch invocation whose disposer ran before it started', async () => {
|
|
|
const { ctx, provider } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
const watcher = vi.fn()
|
|
|
const dispose = scope.watch(watcher)
|
|
|
// The commit chains the invocation as a microtask; the disposer runs in
|
|
|
@@ -594,7 +592,7 @@ describe('third review regressions', () => {
|
|
|
|
|
|
it('waits for an in-flight watch invocation at service dispose', async () => {
|
|
|
const { ctx, provider, fiber } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
let release: (() => void) | undefined
|
|
|
let finished = false
|
|
|
scope.watch(async () => {
|
|
|
@@ -614,7 +612,7 @@ describe('third review regressions', () => {
|
|
|
|
|
|
it('rejects a Date at its path before anything persists', async () => {
|
|
|
const { ctx, provider } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
|
|
+ const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
|
|
|
await expect(scope.update({ value: { at: new Date(0) } }))
|
|
|
.rejects.toThrow(/JSON-compatible.*Date at \$\.value\.at/)
|
|
|
expect(provider.persisted).toEqual([])
|
|
|
@@ -629,13 +627,13 @@ describe('third review regressions', () => {
|
|
|
['a class instance', { value: Object.create({ marker: true }) as object }, /non-plain object at \$\.value/],
|
|
|
])('rejects %s that structuredClone would admit', async (_label, patch, message) => {
|
|
|
const { ctx } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
|
|
+ const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
|
|
|
await expect(scope.update(patch)).rejects.toThrow(message)
|
|
|
})
|
|
|
|
|
|
it('rejects a circular patch instead of storing an alias-looped document', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
|
|
+ const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
|
|
|
const cyclic: Record<string, unknown> = {}
|
|
|
cyclic['self'] = cyclic
|
|
|
await expect(scope.update({ value: cyclic })).rejects.toThrow(/circular reference at \$\.value\.self/)
|
|
|
@@ -646,7 +644,7 @@ describe('third review regressions', () => {
|
|
|
|
|
|
it('accepts one object referenced twice without a cycle', async () => {
|
|
|
const { ctx } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
|
|
+ const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
|
|
|
const shared = { leaf: 1 }
|
|
|
await scope.update({ value: { left: shared, right: shared } })
|
|
|
expect(scope.get()).toEqual({ value: { left: { leaf: 1 }, right: { leaf: 1 } } })
|
|
|
@@ -663,7 +661,7 @@ describe('third review regressions', () => {
|
|
|
ctx.on('settings/updated', boom)
|
|
|
const second = vi.fn()
|
|
|
ctx.on('settings/updated', second)
|
|
|
- ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
|
|
expect(second).toHaveBeenCalledTimes(1)
|
|
|
// Containment gives the rejection a handler; vitest observes no unhandled
|
|
|
@@ -675,7 +673,7 @@ describe('third review regressions', () => {
|
|
|
describe('watch', () => {
|
|
|
it('stops after its disposer runs', async () => {
|
|
|
const { ctx, provider } = await boot()
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
const watcher = vi.fn()
|
|
|
const dispose = scope.watch(watcher)
|
|
|
dispose()
|
|
|
@@ -686,7 +684,7 @@ describe('watch', () => {
|
|
|
it('contains a throwing watcher without blocking the commit or other watchers', async () => {
|
|
|
const { ctx, provider } = await boot()
|
|
|
const events = recordUpdates(ctx)
|
|
|
- const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
+ const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
|
|
scope.watch(() => { throw new Error('watcher boom') })
|
|
|
const second = vi.fn()
|
|
|
scope.watch(second)
|
|
|
@@ -699,7 +697,7 @@ describe('watch', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
-describe('installSettingsSection', () => {
|
|
|
+describe('SettingsProvider.installSection', () => {
|
|
|
const HelperSchema: z<{ theme: string }> = z.object({
|
|
|
theme: z.string().default('default'),
|
|
|
})
|
|
|
@@ -709,13 +707,15 @@ describe('installSettingsSection', () => {
|
|
|
const entry = { theme: 'entry' }
|
|
|
let current: () => { theme: string } = () => entry
|
|
|
let changes = 0
|
|
|
- installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
|
|
- setSource: (source) => {
|
|
|
- current = source
|
|
|
- },
|
|
|
- onChange: () => {
|
|
|
- changes += 1
|
|
|
- },
|
|
|
+ ctx.inject(['settings'], (settingsCtx) => {
|
|
|
+ settingsCtx.settings.installSection(ctx, 'helper-ns', HelperSchema, entry, {
|
|
|
+ setSource: (source) => {
|
|
|
+ current = source
|
|
|
+ },
|
|
|
+ onChange: () => {
|
|
|
+ changes += 1
|
|
|
+ },
|
|
|
+ })
|
|
|
})
|
|
|
// No settings service mounted: nothing ran, the entry stays authoritative.
|
|
|
expect(current()).toEqual({ theme: 'entry' })
|
|
|
@@ -728,7 +728,7 @@ describe('installSettingsSection', () => {
|
|
|
})
|
|
|
expect(changes).toBe(1)
|
|
|
|
|
|
- await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' })
|
|
|
+ await ctx.settings.update('helper-ns', { theme: 'live' })
|
|
|
await vi.waitFor(() => {
|
|
|
expect(changes).toBe(2)
|
|
|
})
|
|
|
@@ -749,7 +749,7 @@ describe('installSettingsSection', () => {
|
|
|
const consumer = ctx.plugin({
|
|
|
inject: ['settings'],
|
|
|
apply: (child: Context) => {
|
|
|
- installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
|
|
+ child.settings.installSection(child, 'helper-ns', HelperSchema, entry, {
|
|
|
setSource: (source) => {
|
|
|
current = source
|
|
|
},
|
|
|
@@ -782,7 +782,7 @@ describe('installSettingsSection', () => {
|
|
|
const consumer = ctx.plugin({
|
|
|
inject: ['settings'],
|
|
|
apply: (child: Context) => {
|
|
|
- installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
|
|
+ child.settings.installSection(child, 'helper-ns', HelperSchema, entry, {
|
|
|
setSource: (source) => {
|
|
|
current = source
|
|
|
},
|
|
|
@@ -817,8 +817,8 @@ describe('mutate (path-addressed writes)', () => {
|
|
|
reasoning: z.string(),
|
|
|
})
|
|
|
|
|
|
- const KEYED = settingsNamespace('keyed')
|
|
|
- const NESTED = settingsNamespace('workspace')
|
|
|
+ const KEYED = 'keyed'
|
|
|
+ const NESTED = 'workspace'
|
|
|
|
|
|
async function mounted(doc: Record<string, unknown>) {
|
|
|
const ctx = new Context()
|
|
|
@@ -922,7 +922,7 @@ describe('mutate (path-addressed writes)', () => {
|
|
|
})
|
|
|
|
|
|
describe('revision and conflict detection', () => {
|
|
|
- const REV = settingsNamespace('rev')
|
|
|
+ const REV = 'rev'
|
|
|
const RevSchema: z<{ a: string; b: string }> = z.object({
|
|
|
a: z.string().default('base-a'),
|
|
|
b: z.string(),
|