Add Prettier formatting and Gitea Actions CI (format, typecheck, test, build)
CI / Node 20 (push) Successful in 1m49s
CI / Node 22 (push) Successful in 1m52s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
Claude
2026-10-23 07:29:38 +00:00
parent 5a370f8c78
commit e27541c46a
24 changed files with 341 additions and 209 deletions
+2 -1
View File
@@ -32,7 +32,8 @@ describe('encodeIndexValue', () => {
fc.assert(
fc.property(indexValue, separators, (v, sep) => {
const decoded = decodeIndexValue(encodeIndexValue(v, sep), sep)
if (typeof v === 'number' && v === 0) expect(decoded).toBe(0) // -0 normalised
if (typeof v === 'number' && v === 0)
expect(decoded).toBe(0) // -0 normalised
else expect(decoded).toEqual(v)
}),
)
+1 -10
View File
@@ -1,15 +1,6 @@
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import {
escapeSegment,
joinPath,
pathKeys,
splitPath,
succ,
toSegment,
unescapeSegment,
validateSeparator,
} from '../../src/core/path.js'
import { escapeSegment, joinPath, pathKeys, splitPath, succ, toSegment, unescapeSegment, validateSeparator } from '../../src/core/path.js'
import { InvalidKeyError } from '../../src/errors.js'
const separators = fc.constantFrom('/', ':', '.', '|', '#', '\\', '!')
+37 -25
View File
@@ -157,7 +157,7 @@ describe.each([
it('evaluates comparison, logical and element operators', async () => {
const { users } = await seed()
// Unsorted results come in index or key order depending on the plan; compare as sets.
const q = async (f: Filter) => (await keysOf(users.find(f))).sort()
const q = async (f: Filter) => (await keysOf(users.find(f))).sort()
expect(await q({})).toEqual(['a', 'b', 'c', 'd', 'e'])
expect(await q({ age: 20 })).toEqual(['b', 'd'])
expect(await q({ age: { $gte: 20, $lt: 40 } })).toEqual(['a', 'b', 'd'])
@@ -186,7 +186,9 @@ describe.each([
expect(await keysOf(users.find({}, { sort: { name: -1 }, skip: 1, limit: 2 }))).toEqual(['d', 'c'])
expect(await keysOf(users.find({ age: { $gte: 20 } }, { sort: { age: -1 }, limit: 2 }))).toEqual(['c', 'a'])
expect(await keysOf(users.find({ 'address.city': 'London' }, { sort: { age: 1 } }))).toEqual(['e', 'a', 'c'])
expect(await collect(users.find({ age: 36 }, { projection: { name: 1, 'address.city': 1 } }))).toEqual([{ key: 'a', data: { name: 'Ada', address: { city: 'London' } } }])
expect(await collect(users.find({ age: 36 }, { projection: { name: 1, 'address.city': 1 } }))).toEqual([
{ key: 'a', data: { name: 'Ada', address: { city: 'London' } } },
])
expect(await collect(users.find({ age: 36 }, { projection: { tags: 0, address: 0, email: 0 } }))).toEqual([{ key: 'a', data: { name: 'Ada', age: 36 } }])
expect(await users.findOne({ email: 'bob@x' })).toEqual({ key: 'b', data: expect.objectContaining({ name: 'Bob' }) })
expect(await users.findOne({ email: 'nobody' })).toBeUndefined()
@@ -278,7 +280,11 @@ describe.each([
it('catchUp() repairs a subtree', async () => {
const { users, docs, events } = await lagged()
await events.child('users').child('9').child('posts').append('p', [{ op: 'set', doc: { createdAt: 'x' } }])
await events
.child('users')
.child('9')
.child('posts')
.append('p', [{ op: 'set', doc: { createdAt: 'x' } }])
expect(await docs.catchUp()).toBe(2)
expect(await users.get('1')).toEqual({ n: 2, email: 'b@x' })
expect(await users.child('9').child('posts').get('p')).toEqual({ createdAt: 'x' })
@@ -339,28 +345,34 @@ describe('property: indexed find equals unindexed find', () => {
it('returns the same results in the same order', async () => {
await fc.assert(
fc.asyncProperty(fc.array(fc.tuple(fc.constantFrom('1', '2', '3', '4', '5'), doc), { maxLength: 12 }), filter, sort, fc.option(fc.integer({ min: 1, max: 4 }), { nil: undefined }), async (writes, f, s, limit) => {
const plain = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(new InMemoryKVDriver())), { warnOnFullScan: false }).child('users')
const indexed = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(new InMemoryKVDriver())), {
warnOnFullScan: false,
indexes: { users: userIndexes.users!.map((i) => ({ ...i, unique: false })) },
}).child('users')
for (const [k, v] of writes) {
await plain.set(k, v)
await indexed.set(k, v)
}
const opts = { ...(s ? { sort: s } : {}), ...(limit ? { limit } : {}) }
const a = await collect(plain.find(f, opts))
const b = await collect(indexed.find(f, opts))
if (s) {
expect(b).toEqual(a)
return
}
// Unsorted order depends on the plan: compare as sets (a limited result is any subset of that size).
const all = new Set((await collect(plain.find(f))).map((r) => r.key))
expect(b).toHaveLength(a.length)
for (const r of b) expect(all.has(r.key)).toBe(true)
}),
fc.asyncProperty(
fc.array(fc.tuple(fc.constantFrom('1', '2', '3', '4', '5'), doc), { maxLength: 12 }),
filter,
sort,
fc.option(fc.integer({ min: 1, max: 4 }), { nil: undefined }),
async (writes, f, s, limit) => {
const plain = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(new InMemoryKVDriver())), { warnOnFullScan: false }).child('users')
const indexed = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(new InMemoryKVDriver())), {
warnOnFullScan: false,
indexes: { users: userIndexes.users!.map((i) => ({ ...i, unique: false })) },
}).child('users')
for (const [k, v] of writes) {
await plain.set(k, v)
await indexed.set(k, v)
}
const opts = { ...(s ? { sort: s } : {}), ...(limit ? { limit } : {}) }
const a = await collect(plain.find(f, opts))
const b = await collect(indexed.find(f, opts))
if (s) {
expect(b).toEqual(a)
return
}
// Unsorted order depends on the plan: compare as sets (a limited result is any subset of that size).
const all = new Set((await collect(plain.find(f))).map((r) => r.key))
expect(b).toHaveLength(a.length)
for (const r of b) expect(all.has(r.key)).toBe(true)
},
),
{ numRuns: 300 },
)
})
+7 -1
View File
@@ -45,7 +45,13 @@ describe('filter semantics', () => {
describe('update resolution', () => {
it('$pull by sub-document filter and by condition', () => {
const state = { items: [{ k: 1, v: 'a' }, { k: 2, v: 'b' }], n: [1, 5, 9] }
const state = {
items: [
{ k: 1, v: 'a' },
{ k: 2, v: 'b' },
],
n: [1, 5, 9],
}
const patch = resolveUpdate(state, { $pull: { items: { k: 2 }, n: { $gte: 5 } } })
expect(applyPatch(state, patch)).toEqual({ items: [{ k: 1, v: 'a' }], n: [1] })
})
+1 -2
View File
@@ -40,8 +40,7 @@ export class CountingDriver implements KVDriver {
readonly inner: KVDriver = new InMemoryKVDriver(),
hide: Capability[] = [],
) {
if (inner.setIfAbsent && !hide.includes('setIfAbsent'))
this.setIfAbsent = (e: [string, unknown][]) => (this.#count('setIfAbsent'), inner.setIfAbsent!(e))
if (inner.setIfAbsent && !hide.includes('setIfAbsent')) this.setIfAbsent = (e: [string, unknown][]) => (this.#count('setIfAbsent'), inner.setIfAbsent!(e))
if (inner.batch && !hide.includes('batch')) this.batch = (ops: BatchOp[]) => (this.#count('batch'), inner.batch!(ops))
if (inner.deleteRange && !hide.includes('deleteRange'))
this.deleteRange = (gte: string, lt: string) => (this.#count('deleteRange'), inner.deleteRange!(gte, lt))
+14 -3
View File
@@ -204,7 +204,10 @@ describe('index declarations', () => {
})
describe('property: findBy equals brute-force filter', () => {
const val = fc.record({ age: fc.option(fc.integer({ min: -3, max: 3 }), { nil: undefined }), tags: fc.array(fc.constantFrom('a', 'b', 'c/d'), { maxLength: 3 }) })
const val = fc.record({
age: fc.option(fc.integer({ min: -3, max: 3 }), { nil: undefined }),
tags: fc.array(fc.constantFrom('a', 'b', 'c/d'), { maxLength: 3 }),
})
const op = fc.oneof(
fc.record({ t: fc.constant('set' as const), k: fc.constantFrom('1', '2', '3', 'x/y'), v: val }),
fc.record({ t: fc.constant('del' as const), k: fc.constantFrom('1', '2', '3', 'x/y') }),
@@ -226,11 +229,19 @@ describe('property: findBy equals brute-force filter', () => {
}
}
const got = (await collect(users.findBy('byAge', { gte: lo, lt: hi }))).map(([k]) => k).sort()
const exp = [...model].filter(([, v]) => v.age !== undefined && v.age >= lo && v.age < hi).map(([k]) => k).sort()
const exp = [...model]
.filter(([, v]) => v.age !== undefined && v.age >= lo && v.age < hi)
.map(([k]) => k)
.sort()
expect(got).toEqual(exp)
for (const tag of ['a', 'b', 'c/d']) {
const g = (await collect(users.findBy('byTag', tag))).map(([k]) => k).sort()
expect(g).toEqual([...model].filter(([, v]) => v.tags?.includes(tag)).map(([k]) => k).sort())
expect(g).toEqual(
[...model]
.filter(([, v]) => v.tags?.includes(tag))
.map(([k]) => k)
.sort(),
)
}
// No stale entries are ever left behind by clean writes.
let entryCount = 0
+51 -47
View File
@@ -293,56 +293,60 @@ describe('model-based (random ops vs reference Map)', () => {
] as const) {
it(`matches the model (${name})`, async () => {
await fc.assert(
fc.asyncProperty(fc.array(opArb, { maxLength: 30 }), fc.array(fc.array(segArb, { maxLength: 2 }), { minLength: 1, maxLength: 3 }), async (ops, probes) => {
const db = new KVStorage(make())
const model = new Map<string, unknown>()
for (const op of ops) {
switch (op.t) {
case 'set': {
if (op.selfForm) await nodeAt(db, op.path).set(op.value)
else await nodeAt(db, op.path.slice(0, -1)).set(op.path.at(-1)!, op.value)
model.set(enc(op.path), op.value)
break
}
case 'delete': {
if (op.selfForm) await nodeAt(db, op.path).delete()
else await nodeAt(db, op.path.slice(0, -1)).delete(op.path.at(-1)!)
model.delete(enc(op.path))
break
}
case 'deleteTree': {
await nodeAt(db, op.path).deleteTree()
for (const k of [...model.keys()]) if (isPrefix(op.path, JSON.parse(k))) model.delete(k)
break
}
case 'setMany': {
await nodeAt(db, op.parent).setMany(op.entries)
for (const [k, v] of op.entries) model.set(enc([...op.parent, k]), v)
break
fc.asyncProperty(
fc.array(opArb, { maxLength: 30 }),
fc.array(fc.array(segArb, { maxLength: 2 }), { minLength: 1, maxLength: 3 }),
async (ops, probes) => {
const db = new KVStorage(make())
const model = new Map<string, unknown>()
for (const op of ops) {
switch (op.t) {
case 'set': {
if (op.selfForm) await nodeAt(db, op.path).set(op.value)
else await nodeAt(db, op.path.slice(0, -1)).set(op.path.at(-1)!, op.value)
model.set(enc(op.path), op.value)
break
}
case 'delete': {
if (op.selfForm) await nodeAt(db, op.path).delete()
else await nodeAt(db, op.path.slice(0, -1)).delete(op.path.at(-1)!)
model.delete(enc(op.path))
break
}
case 'deleteTree': {
await nodeAt(db, op.path).deleteTree()
for (const k of [...model.keys()]) if (isPrefix(op.path, JSON.parse(k))) model.delete(k)
break
}
case 'setMany': {
await nodeAt(db, op.parent).setMany(op.entries)
for (const [k, v] of op.entries) model.set(enc([...op.parent, k]), v)
break
}
}
}
}
for (const [k, v] of model) expect(await nodeAt(db, JSON.parse(k)).get()).toEqual(v)
for (const parent of probes) {
const node = nodeAt(db, parent)
const exp = expectedChildren(model, parent)
const keys = exp.map((e) => e.key)
expect(await collect(node.keys())).toEqual(keys)
expect(await collect(node.keys({ reverse: true }))).toEqual([...keys].reverse())
expect(await collect(node.entries())).toEqual(exp.filter((e) => e.hasOwn).map((e) => [e.key, e.own]))
expect(await collect(node.entries({ reverse: true }))).toEqual(
exp
.filter((e) => e.hasOwn)
.map((e) => [e.key, e.own])
.reverse(),
)
for (let i = 0; i < keys.length; i++) {
expect(await collect(node.keys({ after: keys[i]! }))).toEqual(keys.slice(i + 1))
expect(await collect(node.keys({ after: keys[i]!, reverse: true }))).toEqual(keys.slice(0, i).reverse())
expect(await collect(node.keys({ limit: i + 1 }))).toEqual(keys.slice(0, i + 1))
for (const [k, v] of model) expect(await nodeAt(db, JSON.parse(k)).get()).toEqual(v)
for (const parent of probes) {
const node = nodeAt(db, parent)
const exp = expectedChildren(model, parent)
const keys = exp.map((e) => e.key)
expect(await collect(node.keys())).toEqual(keys)
expect(await collect(node.keys({ reverse: true }))).toEqual([...keys].reverse())
expect(await collect(node.entries())).toEqual(exp.filter((e) => e.hasOwn).map((e) => [e.key, e.own]))
expect(await collect(node.entries({ reverse: true }))).toEqual(
exp
.filter((e) => e.hasOwn)
.map((e) => [e.key, e.own])
.reverse(),
)
for (let i = 0; i < keys.length; i++) {
expect(await collect(node.keys({ after: keys[i]! }))).toEqual(keys.slice(i + 1))
expect(await collect(node.keys({ after: keys[i]!, reverse: true }))).toEqual(keys.slice(0, i).reverse())
expect(await collect(node.keys({ limit: i + 1 }))).toEqual(keys.slice(0, i + 1))
}
}
}
}),
},
),
{ numRuns: 300 },
)
}, 60_000)
+24 -19
View File
@@ -36,26 +36,31 @@ describe('ChunkedSortedMap (model-based, tiny chunks)', () => {
it('iterators resume correctly after mutations between steps', () => {
fc.assert(
fc.property(fc.uniqueArray(key, { minLength: 1, maxLength: 60 }), fc.array(fc.tuple(fc.boolean(), key), { maxLength: 40 }), fc.boolean(), (initial, muts, reverse) => {
const m = new ChunkedSortedMap<number>(3)
initial.forEach((k) => m.set(k, 0))
const live = new Set(initial)
const seen: string[] = []
let step = 0
for (const k of m.keys({ reverse })) {
seen.push(k)
const mut = muts[step++]
if (mut) {
const [add, mk] = mut
if (add) (m.set(mk, 1), live.add(mk))
else (m.delete(mk), live.delete(mk))
fc.property(
fc.uniqueArray(key, { minLength: 1, maxLength: 60 }),
fc.array(fc.tuple(fc.boolean(), key), { maxLength: 40 }),
fc.boolean(),
(initial, muts, reverse) => {
const m = new ChunkedSortedMap<number>(3)
initial.forEach((k) => m.set(k, 0))
const live = new Set(initial)
const seen: string[] = []
let step = 0
for (const k of m.keys({ reverse })) {
seen.push(k)
const mut = muts[step++]
if (mut) {
const [add, mk] = mut
if (add) (m.set(mk, 1), live.add(mk))
else (m.delete(mk), live.delete(mk))
}
}
}
// Strictly monotone, and every key present throughout the iteration was visited.
for (let i = 1; i < seen.length; i++) expect(reverse ? seen[i]! < seen[i - 1]! : seen[i]! > seen[i - 1]!).toBe(true)
const stable = initial.filter((k) => !muts.some(([, mk]) => mk === k))
for (const k of stable) expect(seen).toContain(k)
}),
// Strictly monotone, and every key present throughout the iteration was visited.
for (let i = 1; i < seen.length; i++) expect(reverse ? seen[i]! < seen[i - 1]! : seen[i]! > seen[i - 1]!).toBe(true)
const stable = initial.filter((k) => !muts.some(([, mk]) => mk === k))
for (const k of stable) expect(seen).toContain(k)
},
),
{ numRuns: 500 },
)
})
+16 -7
View File
@@ -47,12 +47,18 @@ describe('KV types (section 7)', () => {
db.child('users').child('1').set({ username: 'a', age: 1 })
// @ts-expect-error wrong value type
db.child('users').set('1', { username: 'a' })
db.child('profile').child('9').setMany([
['age', 3],
['username', 'x'],
])
// @ts-expect-error value doesn't match its key
db.child('profile').child('9').setMany([['age', 'x']])
db.child('profile')
.child('9')
.setMany([
['age', 3],
['username', 'x'],
])
db.child('profile')
.child('9')
.setMany([
// @ts-expect-error value doesn't match its key
['age', 'x'],
])
})
it('types listings', () => {
@@ -82,7 +88,10 @@ describe('event types', () => {
orders.child('o1').append([{ type: 'paid' }])
// @ts-expect-error not an OrderEvent
orders.append('o1', [{ type: 'refunded' }])
orders.child('o1').child('shipments').append('s1', [{ carrier: 'x' }])
orders
.child('o1')
.child('shipments')
.append('s1', [{ carrier: 'x' }])
})
it('types read results', () => {