diff --git a/src/collection.ts b/src/collection.ts index 4a010bf..36c6781 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -18,17 +18,45 @@ import type { UpdateChanges, } from './types.js'; -interface IndexDefinition { - readonly fields: string[]; - readonly spec: IndexSpec; - readonly unique: boolean; -} - /** Deep copy used at every boundary so callers can never mutate stored state. */ function clone(value: T): T { return structuredClone(value); } +/** + * A collection index. + * + * A **unique** index keeps a live `entries` map from each document's canonical + * key (the combination of its indexed field values) to the id that holds it. + * Maintaining this map incrementally on every write makes uniqueness checks + * O(1) per document, so inserting `n` documents is O(n) rather than O(n²). + * + * A **non-unique** index has no `entries` map: its direction is recorded but + * nothing is enforced and queries still scan. + */ +class Index { + /** Canonical key -> id of the holder, for unique indexes; `null` otherwise. */ + readonly entries: Map | null; + + constructor( + readonly fields: string[], + readonly spec: IndexSpec, + readonly unique: boolean, + ) { + this.entries = unique ? new Map() : null; + } + + /** The indexed field values of a document (a missing field reads as `null`). */ + valuesOf(doc: Document): unknown[] { + return this.fields.map((field) => getPath(doc, field) ?? null); + } + + /** The canonical lookup key of a document on this index. */ + keyOf(doc: Document): string { + return canonicalKey(this.valuesOf(doc)); + } +} + /** * A MongoDB-like cursor over query results, returned by {@link Collection.list}. * @@ -93,7 +121,7 @@ export class DocumentCursor implements AsyncIterableIterator { */ export class Collection { private readonly documents = new Map(); - private readonly indexes: IndexDefinition[] = []; + private readonly indexes: Index[] = []; /** * @param name Collection name. @@ -134,9 +162,24 @@ export class Collection { if (this.documents.has(id)) { throw new DuplicateKeyError(['_id'], [id]); } - this.assertUnique([...this.documents.values(), doc]); + + // Check every unique index against its key map (O(1) each) before mutating + // anything, so a collision on a later index leaves the collection unchanged. + const additions: Array<{ entries: Map; key: string }> = []; + for (const index of this.indexes) { + if (!index.entries) continue; + const values = index.valuesOf(doc); + const key = canonicalKey(values); + if (index.entries.has(key)) { + throw new DuplicateKeyError(index.fields, values); + } + additions.push({ entries: index.entries, key }); + } this.documents.set(id, doc); + for (const { entries, key } of additions) { + entries.set(key, id); + } return doc; } @@ -183,27 +226,45 @@ export class Collection { ); if (matches.length === 0) return 0; - // Build the proposed documents first; validate before committing anything. - // applyChanges may throw (bad increment / path) — because nothing is written - // to storage until the loop completes, a failure leaves the collection intact. - const proposed = new Map(); + // Build the updated documents up front. applyChanges may throw (bad + // increment / path); since nothing is committed until every check below + // passes, a failure leaves the collection unchanged. + const updated = new Map(); for (const doc of matches) { - const updated = clone(doc); - applyChanges(updated, changes); - proposed.set(doc._id, updated); + const next = clone(doc); + applyChanges(next, changes); + updated.set(doc._id, next); } - const resulting = new Map(this.documents); - for (const [id, doc] of proposed) resulting.set(id, doc); - this.assertUnique(resulting.values()); + this.assertUpdateKeepsUniqueness(updated); - for (const [id, doc] of proposed) this.documents.set(id, doc); - return proposed.size; + // Commit. Per unique index, vacate the updated documents' old keys, then + // claim their new ones — so a swap of two values commits cleanly. + for (const index of this.indexes) { + if (!index.entries) continue; + for (const id of updated.keys()) { + index.entries.delete(index.keyOf(this.documents.get(id)!)); + } + for (const [id, doc] of updated) { + index.entries.set(index.keyOf(doc), id); + } + } + for (const [id, doc] of updated) { + this.documents.set(id, doc); + } + return updated.size; } /** Deletes the document with the given id. Returns whether it existed. */ async delete(id: DocumentId): Promise { - return this.documents.delete(id); + const doc = this.documents.get(id); + if (!doc) return false; + + this.documents.delete(id); + for (const index of this.indexes) { + index.entries?.delete(index.keyOf(doc)); + } + return true; } /** @@ -233,7 +294,7 @@ export class Collection { } const unique = Boolean(options.unique); - const existing = this.indexes.find((def) => sameFields(def.fields, fields)); + const existing = this.indexes.find((index) => sameFields(index.fields, fields)); if (existing) { if (existing.unique !== unique) { throw new DatabaseError( @@ -243,42 +304,59 @@ export class Collection { return; // idempotent } - const def: IndexDefinition = { fields, spec: { ...spec }, unique }; - this.indexes.push(def); - try { - this.assertUnique(this.documents.values()); - } catch (error) { - this.indexes.pop(); // roll back the half-created index - throw error; + const index = new Index(fields, { ...spec }, unique); + // Populate a unique index from existing data, rejecting any duplicate. The + // index is only registered once it has been built successfully. + if (index.entries) { + for (const doc of this.documents.values()) { + const values = index.valuesOf(doc); + const key = canonicalKey(values); + if (index.entries.has(key)) { + throw new DuplicateKeyError(index.fields, values); + } + index.entries.set(key, doc._id); + } } + this.indexes.push(index); } /** Describes the indexes defined on this collection. */ listIndexes(): IndexDescription[] { - return this.indexes.map((def) => ({ - fields: [...def.fields], - spec: { ...def.spec }, - unique: def.unique, + return this.indexes.map((index) => ({ + fields: [...index.fields], + spec: { ...index.spec }, + unique: index.unique, })); } - /** Verifies that the given documents satisfy every unique index. */ - private assertUnique(docs: Iterable): void { - const uniqueIndexes = this.indexes.filter((def) => def.unique); - if (uniqueIndexes.length === 0) return; - - const seen = uniqueIndexes.map(() => new Set()); - for (const doc of docs) { - uniqueIndexes.forEach((def, i) => { - // A missing indexed field indexes as `null` (so a missing field and an - // explicit `null` collide, mirroring MongoDB). - const values = def.fields.map((field) => getPath(doc, field) ?? null); + /** + * Verifies that applying `updated` (id -> new document) violates no unique + * index, consulting each index's key map for O(matches) lookups rather than + * rescanning the whole collection. Throws on the first conflict. + */ + private assertUpdateKeepsUniqueness( + updated: Map, + ): void { + for (const index of this.indexes) { + if (!index.entries) continue; + const claimed = new Map(); + for (const [id, doc] of updated) { + const values = index.valuesOf(doc); const key = canonicalKey(values); - if (seen[i]!.has(key)) { - throw new DuplicateKeyError(def.fields, values); + + // Two updated documents cannot end up with the same key… + if (claimed.has(key)) { + throw new DuplicateKeyError(index.fields, values); + } + claimed.set(key, id); + + // …nor can an updated document collide with one that is staying put. A + // holder that is itself being updated is vacating this key, so allow it. + const holder = index.entries.get(key); + if (holder !== undefined && !updated.has(holder)) { + throw new DuplicateKeyError(index.fields, values); } - seen[i]!.add(key); - }); + } } } } diff --git a/tests/cursor.test.ts b/tests/cursor.test.ts index e9df63c..3ccbf9e 100644 --- a/tests/cursor.test.ts +++ b/tests/cursor.test.ts @@ -96,5 +96,36 @@ describe('list cursor: toArray + limit', () => { const rest = await cursor.toArray(); expect(rest.map((d) => d.n)).toEqual([2, 3, 4, 5]); }); + + it('serves independent cursors from the same collection', async () => { + const a = people.list(); + const b = people.list(); + await a.next(); // advance only a + expect(await b.toArray()).toHaveLength(5); // b is independent + expect(await a.toArray()).toHaveLength(4); // a resumes after its first item + }); + + it('is unaffected by a delete after the cursor was created', async () => { + const cursor = people.list(); + await people.delete('1'); + expect(await cursor.toArray()).toHaveLength(5); // snapshot still holds it + }); + + it('yields the pre-update version of a document (snapshot)', async () => { + const cursor = people.list({ _id: '1' }); + await people.update({ _id: '1' }, { n: 999 }); + const [doc] = await cursor.toArray(); + expect(doc?.n).toBe(1); // old value, not 999 + }); + + it('supports early termination with break', async () => { + let seen = 0; + for await (const doc of people.list()) { + void doc; + seen += 1; + if (seen === 2) break; + } + expect(seen).toBe(2); + }); }); }); diff --git a/tests/database.test.ts b/tests/database.test.ts index 4370287..cb7a85e 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -55,4 +55,14 @@ describe('Database', () => { await db.newCollection('b'); expect(db.listCollections()).toEqual(['a', 'b']); }); + + it('drops all data when a collection is removed and re-created', async () => { + const db = new Database(); + const c = await db.newCollection('c'); + await c.insert({ _id: 'x' }); + await db.removeCollection('c'); + const fresh = await db.newCollection('c'); // same name is free again + expect(fresh.size).toBe(0); + expect(await fresh.get('x')).toBeNull(); + }); }); diff --git a/tests/ensure-index.test.ts b/tests/ensure-index.test.ts index 828a366..e868980 100644 --- a/tests/ensure-index.test.ts +++ b/tests/ensure-index.test.ts @@ -190,4 +190,54 @@ describe('ensureIndex', () => { ); expect(users.size).toBe(before); }); + + it('treats compound indexes with different field order as distinct', async () => { + await users.ensureIndex({ a: 1, b: 1 }, { unique: true }); + await users.ensureIndex({ b: 1, a: 1 }, { unique: true }); + expect(users.listIndexes()).toHaveLength(2); + }); + + it('produces a stable key for out-of-domain nested values (undefined)', async () => { + await users.ensureIndex({ meta: 1 }, { unique: true }); + await users.insert({ _id: 'a', meta: { x: undefined } }); + await expect( + users.insert({ _id: 'b', meta: { x: undefined } }), + ).rejects.toBeInstanceOf(DuplicateKeyError); + }); + + it('indexes Date values by instant', async () => { + await users.ensureIndex({ at: 1 }, { unique: true }); + await users.insert({ at: new Date('2024-01-01T00:00:00.000Z') }); + await users.insert({ at: new Date('2024-02-01T00:00:00.000Z') }); // distinct + await expect( + users.insert({ at: new Date('2024-01-01T00:00:00.000Z') }), + ).rejects.toBeInstanceOf(DuplicateKeyError); + }); + + it('indexes array values by element and order', async () => { + await users.ensureIndex({ tags: 1 }, { unique: true }); + await users.insert({ tags: ['a', 'b'] }); + await users.insert({ tags: ['b', 'a'] }); // different order → distinct + await expect(users.insert({ tags: ['a', 'b'] })).rejects.toBeInstanceOf( + DuplicateKeyError, + ); + }); + + it('deletes cleanly on a collection that has only a non-unique index', async () => { + await users.ensureIndex({ city: 1 }); // non-unique → no key map to maintain + await users.insert({ _id: 'x', city: 'NYC' }); + expect(await users.delete('x')).toBe(true); + expect(users.size).toBe(0); + }); + + it('returns defensive copies from listIndexes', async () => { + await users.ensureIndex({ a: 1, b: -1 }); + const first = users.listIndexes(); + (first[0]!.fields as string[]).push('zzz'); + (first[0]!.spec as Record).a = 999; + // A later call is unaffected by mutating an earlier result. + const second = users.listIndexes(); + expect(second[0]!.fields).toEqual(['a', 'b']); + expect(second[0]!.spec).toEqual({ a: 1, b: -1 }); + }); }); diff --git a/tests/filter.test.ts b/tests/filter.test.ts index 68846ee..66bb237 100644 --- a/tests/filter.test.ts +++ b/tests/filter.test.ts @@ -64,6 +64,13 @@ describe('deepEqual', () => { expect(deepEqual([1, 2], { 0: 1, 1: 2 })).toBe(false); }); + it('compares arrays of objects deeply (element key order independent)', () => { + expect(deepEqual([{ a: 1 }, { b: 2 }], [{ a: 1 }, { b: 2 }])).toBe(true); + expect(deepEqual([{ a: 1, b: 2 }], [{ b: 2, a: 1 }])).toBe(true); + expect(deepEqual([{ a: 1 }], [{ a: 2 }])).toBe(false); + expect(deepEqual([{ a: 1 }, { b: 2 }], [{ b: 2 }, { a: 1 }])).toBe(false); + }); + it('compares objects regardless of key order', () => { expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); diff --git a/tests/index-maintenance.test.ts b/tests/index-maintenance.test.ts new file mode 100644 index 0000000..4feb50f --- /dev/null +++ b/tests/index-maintenance.test.ts @@ -0,0 +1,145 @@ +import { performance } from 'node:perf_hooks'; + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + Collection, + Database, + DuplicateKeyError, + dec, + inc, +} from '../src/index.js'; +import type { Document } from '../src/index.js'; + +async function values(c: Collection, field = 'n'): Promise { + const docs = await c.list().toArray(); + return docs.map((d) => d[field] as number).sort((a, b) => a - b); +} + +describe('incremental unique-index maintenance', () => { + let c: Collection; + + beforeEach(async () => { + c = await new Database().newCollection('c'); + await c.ensureIndex({ n: 1 }, { unique: true }); + await c.insert({ _id: 'a', n: 1 }); + await c.insert({ _id: 'b', n: 2 }); + await c.insert({ _id: 'd', n: 3 }); + }); + + describe('cascading updates (a key held by another updated document)', () => { + it('increments every document by one without a false collision', async () => { + const count = await c.update({}, { n: inc(1) }); + expect(count).toBe(3); + expect(await values(c)).toEqual([2, 3, 4]); + }); + + it('decrements every document by one without a false collision', async () => { + const count = await c.update({}, { n: dec(1) }); + expect(count).toBe(3); + expect(await values(c)).toEqual([0, 1, 2]); + }); + }); + + describe('genuine conflicts are rejected atomically', () => { + it('rejects collapsing several documents onto the same value', async () => { + await expect(c.update({}, { n: 5 })).rejects.toBeInstanceOf( + DuplicateKeyError, + ); + expect(await values(c)).toEqual([1, 2, 3]); // unchanged + }); + + it('rejects moving a document onto a value held by an unchanged one', async () => { + await expect(c.update({ n: 1 }, { n: 2 })).rejects.toBeInstanceOf( + DuplicateKeyError, + ); + expect(await values(c)).toEqual([1, 2, 3]); // unchanged + }); + + it('leaves the index map uncorrupted after a rejected update', async () => { + await expect(c.update({ n: 1 }, { n: 2 })).rejects.toBeInstanceOf( + DuplicateKeyError, + ); + // Both original keys must still be owned by their original holders. + await expect(c.insert({ n: 1 })).rejects.toBeInstanceOf(DuplicateKeyError); + await expect(c.insert({ n: 2 })).rejects.toBeInstanceOf(DuplicateKeyError); + expect((await c.get('a'))?.n).toBe(1); + expect((await c.get('b'))?.n).toBe(2); + }); + }); + + describe('keys are freed when vacated', () => { + it('allows reusing a value after an update moves a document off it', async () => { + await c.update({ n: 1 }, { n: 10 }); + await expect(c.insert({ _id: 'e', n: 1 })).resolves.toBeDefined(); + expect(await values(c)).toEqual([1, 2, 3, 10]); + }); + + it('allows reusing a value after the holder is deleted', async () => { + await c.delete('a'); // frees n=1 + await expect(c.insert({ _id: 'e', n: 1 })).resolves.toBeDefined(); + }); + + it('accepts an update that leaves a document on its own value (no-op)', async () => { + const count = await c.update({ n: 1 }, { n: 1 }); + expect(count).toBe(1); + expect(await values(c)).toEqual([1, 2, 3]); + }); + }); + + describe('compound unique index stays consistent under updates', () => { + it('cascades on the leading field of a compound key', async () => { + const teams = await new Database().newCollection('teams'); + await teams.ensureIndex({ row: 1, seat: 1 }, { unique: true }); + await teams.insert({ _id: '1', row: 1, seat: 1 }); + await teams.insert({ _id: '2', row: 2, seat: 1 }); + await teams.insert({ _id: '3', row: 3, seat: 1 }); + // row: 1,2,3 -> 2,3,4 (seat constant); each lands where the next used to be. + const count = await teams.update({}, { row: inc(1) }); + expect(count).toBe(3); + expect(await values(teams, 'row')).toEqual([2, 3, 4]); + }); + }); +}); + +describe('write complexity is linear (not O(n^2))', () => { + // These guard the persistent-index refactor. Under the old per-insert + // full-collection rescan these would take tens of seconds; linear + // maintenance keeps them well under a second. Bounds are deliberately loose + // to stay robust on slow CI while still catching a quadratic regression. + + it('bulk insert with a unique index stays linear', async () => { + const c = await new Database().newCollection('big'); + await c.ensureIndex({ k: 1 }, { unique: true }); + const N = 10_000; + const start = performance.now(); + for (let i = 0; i < N; i++) await c.insert({ k: i }); + const elapsed = performance.now() - start; + + expect(c.size).toBe(N); + expect(await c.get((await c.list({ k: 7777 }).toArray())[0]!._id)).toMatchObject({ k: 7777 }); + expect(elapsed).toBeLessThan(2000); + }, 20_000); + + it('bulk insert with no index stays linear', async () => { + const c = await new Database().newCollection('big'); + const N = 40_000; + const start = performance.now(); + for (let i = 0; i < N; i++) await c.insert({ k: i }); + const elapsed = performance.now() - start; + + expect(c.size).toBe(N); + expect(elapsed).toBeLessThan(2500); + }, 20_000); + + it('seeding a database via the constructor stays linear', async () => { + const docs: Document[] = []; + for (let i = 0; i < 20_000; i++) docs.push({ _id: String(i), k: i }); + const start = performance.now(); + const db = new Database({ big: docs }); + const elapsed = performance.now() - start; + + expect(db.collection('big').size).toBe(20_000); + expect(elapsed).toBeLessThan(2000); + }, 20_000); +}); diff --git a/tests/insert-get-delete.test.ts b/tests/insert-get-delete.test.ts index e52385e..df0290a 100644 --- a/tests/insert-get-delete.test.ts +++ b/tests/insert-get-delete.test.ts @@ -114,4 +114,34 @@ describe('insert / get / delete', () => { expect(await a.get('x')).not.toBeNull(); expect(await b.get('x')).toBeNull(); }); + + it('accepts an empty string as an explicit _id', async () => { + const doc = await users.insert({ _id: '', name: 'X' }); + expect(doc._id).toBe(''); // kept, not auto-generated + expect((await users.get(''))?.name).toBe('X'); + await expect(users.insert({ _id: '' })).rejects.toBeInstanceOf( + DuplicateKeyError, + ); + }); + + it("does not mutate the caller's input object", async () => { + const input: { name: string; _id?: string } = { name: 'Ada' }; + await users.insert(input); + expect('_id' in input).toBe(false); // the generated _id lands on the copy only + expect(input).toEqual({ name: 'Ada' }); + }); + + it('preserves a Date field as a real Date and isolates nested structures', async () => { + const when = new Date('2024-03-04T05:06:07.000Z'); + await users.insert({ _id: 'u1', when, nested: { arr: [{ x: 1 }] } }); + + const stored = await users.get('u1'); + expect(stored?.when).toBeInstanceOf(Date); + expect((stored?.when as Date).toISOString()).toBe('2024-03-04T05:06:07.000Z'); + + // Mutating the returned nested structure must not affect storage. + (stored?.nested as { arr: { x: number }[] }).arr[0]!.x = 999; + const again = await users.get('u1'); + expect((again?.nested as { arr: { x: number }[] }).arr[0]!.x).toBe(1); + }); }); diff --git a/tests/integration.test.ts b/tests/integration.test.ts new file mode 100644 index 0000000..0d52112 --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { Database, gt, inc, unset } from '../src/index.js'; + +describe('integration', () => { + it('supports a realistic end-to-end workflow', async () => { + const db = new Database({ users: [] }); + const users = db.collection('users'); + await users.ensureIndex({ email: 1 }, { unique: true }); + + await users.insert({ _id: 'u1', name: 'Ada', email: 'ada@x.com', age: 36 }); + await users.insert({ _id: 'u2', name: 'Bob', email: 'bob@x.com', age: 17 }); + await users.insert({ _id: 'u3', name: 'Cay', email: 'cay@x.com', age: 50 }); + + // Tag and count the adults. + const tagged = await users.update({ age: gt(18) }, { adult: true, visits: inc(1) }); + expect(tagged).toBe(2); + const adults = await users.list({ adult: true }).toArray(); + expect(adults.map((u) => u.name).sort()).toEqual(['Ada', 'Cay']); + + // Unique email is still enforced; a colliding insert adds nothing. + await expect(users.insert({ email: 'ada@x.com' })).rejects.toThrow(); + expect(users.size).toBe(3); + + // Remove a field, then a document. + await users.update({ _id: 'u1' }, { adult: unset() }); + const u1 = await users.get('u1'); + expect(u1 && 'adult' in u1).toBe(false); + + expect(await users.delete('u2')).toBe(true); + expect(users.size).toBe(2); + }); + + it('handles many concurrent inserts with distinct ids', async () => { + const c = await new Database().newCollection('c'); + await Promise.all( + Array.from({ length: 300 }, (_, i) => c.insert({ _id: `k${i}`, i })), + ); + expect(c.size).toBe(300); + }); + + it('lets exactly one of several same-_id inserts win', async () => { + const c = await new Database().newCollection('c'); + const results = await Promise.allSettled( + Array.from({ length: 5 }, () => c.insert({ _id: 'dup' })), + ); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((r) => r.status === 'rejected')).toHaveLength(4); + expect(c.size).toBe(1); + }); + + it('auto-generates unique ids across many inserts', async () => { + const c = await new Database().newCollection('c'); + for (let i = 0; i < 500; i++) await c.insert({ i }); + const ids = new Set((await c.list().toArray()).map((d) => d._id)); + expect(ids.size).toBe(500); + }); + + it('matches nested objects by deep equality regardless of key order', async () => { + const c = await new Database().newCollection('c'); + await c.insert({ _id: '1', meta: { a: 1, b: { c: 2, d: 3 } } }); + const hit = await c.list({ meta: { b: { d: 3, c: 2 }, a: 1 } }).toArray(); + expect(hit.map((d) => d._id)).toEqual(['1']); + }); +}); diff --git a/tests/invariants.test.ts b/tests/invariants.test.ts new file mode 100644 index 0000000..83d3369 --- /dev/null +++ b/tests/invariants.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { Database } from '../src/index.js'; + +/** Small deterministic PRNG (mulberry32) so a failure is reproducible. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +describe('invariants under randomized operations', () => { + it('preserves _id and unique-key invariants across many insert/update/delete ops', async () => { + const c = await new Database().newCollection('c'); + await c.ensureIndex({ k: 1 }, { unique: true }); + + const rand = mulberry32(0xc0ffee); + const pick = (n: number) => Math.floor(rand() * n); + + for (let i = 0; i < 4000; i++) { + const id = `id${pick(60)}`; + const k = pick(30); + const roll = rand(); + try { + if (roll < 0.55) await c.insert({ _id: id, k }); + else if (roll < 0.8) await c.update({ _id: id }, { k }); + else await c.delete(id); + } catch { + // Duplicate _id / duplicate-key collisions are expected and fine. + } + } + + const docs = await c.list().toArray(); + + // The reported size agrees with what is actually listable. + expect(docs).toHaveLength(c.size); + + // Every _id is unique and individually retrievable. + const ids = docs.map((d) => d._id); + expect(new Set(ids).size).toBe(docs.length); + for (const d of docs) { + expect(await c.get(d._id)).toEqual(d); + } + + // The unique-index invariant holds: no two documents share a `k`. + const ks = docs.map((d) => d.k); + expect(new Set(ks).size).toBe(docs.length); + }); + + it('is reproducible — two identical seeded runs reach the same state', async () => { + const run = async () => { + const c = await new Database().newCollection('c'); + await c.ensureIndex({ k: 1 }, { unique: true }); + const rand = mulberry32(42); + const pick = (n: number) => Math.floor(rand() * n); + for (let i = 0; i < 1500; i++) { + const id = `id${pick(40)}`; + const k = pick(20); + const roll = rand(); + try { + if (roll < 0.6) await c.insert({ _id: id, k }); + else if (roll < 0.8) await c.update({ _id: id }, { k }); + else await c.delete(id); + } catch { + /* expected */ + } + } + return (await c.list().toArray()) + .map((d) => `${d._id}:${d.k}`) + .sort(); + }; + expect(await run()).toEqual(await run()); + }); +}); diff --git a/tests/list-filter.test.ts b/tests/list-filter.test.ts index f1712dc..b545291 100644 --- a/tests/list-filter.test.ts +++ b/tests/list-filter.test.ts @@ -98,4 +98,14 @@ describe('list + filter', () => { expect(await collect(people.list({ country: 'US' }))).toHaveLength(1); expect(await collect(people.list({ country: 'US' }))).toHaveLength(1); }); + + it('matches a bare Date value by instant equality', async () => { + const c = await new Database().newCollection('events'); + await c.insert({ _id: '1', at: new Date('2024-05-06T07:08:09.000Z') }); + await c.insert({ _id: '2', at: new Date('2020-01-01T00:00:00.000Z') }); + const hit = await collect( + c.list({ at: new Date('2024-05-06T07:08:09.000Z') }), + ); + expect(hit.map((d) => d._id)).toEqual(['1']); + }); }); diff --git a/tests/nested-paths.test.ts b/tests/nested-paths.test.ts index 70828e5..a142bdd 100644 --- a/tests/nested-paths.test.ts +++ b/tests/nested-paths.test.ts @@ -1,6 +1,13 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { Collection, Database, DuplicateKeyError, gt, ne } from '../src/index.js'; +import { + Collection, + Database, + DuplicateKeyError, + InvalidUpdateError, + gt, + ne, +} from '../src/index.js'; import type { Document } from '../src/index.js'; async function collect(iter: AsyncIterable): Promise { @@ -69,4 +76,36 @@ describe('dot-path unique indexes', () => { users.insert({ profile: { email: 'b@x.com' } }), ).resolves.toBeDefined(); }); + + it('rejects creating a unique dot-path index when existing data already collides', async () => { + const users = await new Database().newCollection('users'); + await users.insert({ profile: { email: 'a@x.com' } }); + await users.insert({ profile: { email: 'a@x.com' } }); + await expect( + users.ensureIndex({ 'profile.email': 1 }, { unique: true }), + ).rejects.toBeInstanceOf(DuplicateKeyError); + expect(users.listIndexes()).toEqual([]); // not registered on failure + }); +}); + +describe('dot-paths do not traverse arrays', () => { + it('resolves an array-index dot-path to undefined on read', async () => { + const c = await new Database().newCollection('c'); + await c.insert({ _id: '1', tags: ['x', 'y'] }); + // Arrays terminate a path, so 'tags.0' never matches… + expect(await collect(c.list({ 'tags.0': 'x' }))).toEqual([]); + // …but whole-array equality still works. + expect((await collect(c.list({ tags: ['x', 'y'] }))).map((d) => d._id)).toEqual( + ['1'], + ); + }); + + it('rejects updating a dot-path through an array', async () => { + const c = await new Database().newCollection('c'); + await c.insert({ _id: '1', tags: ['x'] }); + await expect( + c.update({ _id: '1' }, { 'tags.0': 'y' }), + ).rejects.toBeInstanceOf(InvalidUpdateError); + expect((await c.get('1'))?.tags).toEqual(['x']); // unchanged + }); }); diff --git a/tests/update.test.ts b/tests/update.test.ts index a98a58e..cddb487 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -5,7 +5,9 @@ import { Database, DuplicateKeyError, ImmutableFieldError, + InvalidUpdateError, gt, + inc, } from '../src/index.js'; describe('update', () => { @@ -127,4 +129,30 @@ describe('update', () => { ).rejects.toBeInstanceOf(DuplicateKeyError); expect(users.size).toBe(before); }); + + it('is atomic when an operator fails on a later matched document', async () => { + const c = await new Database().newCollection('c'); + await c.insert({ _id: 'a', score: 1 }); + await c.insert({ _id: 'b', score: 'high' }); // non-numeric + await expect(c.update({}, { score: inc(1) })).rejects.toBeInstanceOf( + InvalidUpdateError, + ); + // Neither document changed — not even the one processed before the failure. + expect((await c.get('a'))?.score).toBe(1); + expect((await c.get('b'))?.score).toBe('high'); + }); + + it('treats an empty changes object as a no-op that still counts matches', async () => { + const n = await users.update({ active: true }, {}); + expect(n).toBe(2); + expect((await users.get('1'))?.age).toBe(36); // unchanged + }); + + it('updates normally when a non-unique index is present', async () => { + await users.ensureIndex({ active: 1 }); // non-unique → not enforced + const n = await users.update({ active: true }, { active: false }); + expect(n).toBe(2); + expect((await users.get('1'))?.active).toBe(false); + expect((await users.get('2'))?.active).toBe(false); + }); });