Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 78 additions & 19 deletions app/context/Database/Collection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/member-ordering */
import type { ChangeFn } from "@automerge/automerge"
import { NO_OP } from "lib/constants"
import { $, E, type Types } from "lib/Effect"
import { E, type Types } from "lib/Effect"
import type { Root, RootEncoded } from "schema/Root"
import type { KeyNotFoundError } from "./Errors"
import { NonUniqueIndex, UniqueIndex } from "./Indexes"
Expand Down Expand Up @@ -67,7 +68,7 @@ export abstract class Collection<

constructor(
/** The initial items to populate the collection with */
items: Item[] | Record<string, Item>,
items: Item[] | Record<string, Item> = [],

/**
* The automerge-repo change function returned by useDoc<Root>.
Expand All @@ -79,13 +80,71 @@ export abstract class Collection<
this.index = new UniqueIndex(items, "id" as StringKeyOf<Item>)
}

// NON-EFFECTFUL (SYNC) API FOR USE BY APPLICATION

/** Returns all items in the collection. */
all() {
return this.index.all()
return E.runSync(this.effect_all())
}

/** Finds an item by its ID. */
find(id: string) {
return E.runSync(this.effect_find(id))
}

/**
* Finds an item or items using an index.
* - If the index is unique (first overload), returns a single item, and throws if none is found.
* - If the index is non-unique (second overload), returns an array zero or more of items.
*
* @example `contactCollection.findBy("userName", "alice")` // returns one contact
* @example `contactCollection.findBy("company", "DevResults")` // returns an array of contacts
*/
findBy(...args: Parameters<typeof this.effect_findBy>) {
return E.runSync(this.effect_findBy(...args))
}

/** Adds an item to the collection */
add(arg: Item | Item[]) {
return E.runSync(this.effect_add(arg))
}

/** Updates an item given an object containing its ID and one or more updated properties. */
update(item: { id: IdOf<Item> } & Partial<Item>) {
E.runSync(this.effect_update(item))
}

/** Removes an item from a collection, given its ID. */
destroy(id: IdOf<Item>) {
E.runSync(this.effect_destroy(id))
}

/** Removes all items from the collection. */
destroyAll() {
E.runSync(this.effect_destroyAll())
}

// EFFECTFUL API FOR USE WITHIN DATA LAYER

public effect = {
all: this.effect_all.bind(this),
find: this.effect_find.bind(this),
findBy: this.effect_findBy.bind(this),
add: this.effect_add.bind(this),
update: this.effect_update.bind(this),
destroy: this.effect_destroy.bind(this),
destroyAll: this.effect_destroyAll.bind(this),
}

// PRIVATE

/** Returns all items in the collection. Returns an effect. */
private effect_all() {
return this.index.all()
}

/** Finds an item by its ID. Returns an effect. */
private effect_find(id: string) {
return this.index.find(id)
}

Expand All @@ -96,24 +155,26 @@ export abstract class Collection<
*
* @example `contactCollection.findBy("userName", "alice")` // returns one contact
* @example `contactCollection.findBy("company", "DevResults")` // returns an array of contacts
*
* Returns an effect.
*/
findBy<IndexName extends UniqueIndexName<this>>(
private effect_findBy<IndexName extends UniqueIndexName<this>>(
indexName: IndexName,
value: any,
): E.Effect<never, KeyNotFoundError> | E.Effect<Item>

findBy<IndexName extends NonUniqueIndexName<this>>(
private effect_findBy<IndexName extends NonUniqueIndexName<this>>(
indexName: IndexName,
value: any,
): E.Effect<Item[]>

findBy<IndexName extends AnyIndexName<this>>(indexName: IndexName, value: any) {
private effect_findBy<IndexName extends AnyIndexName<this>>(indexName: IndexName, value: any) {
const index = this.getIndex(indexName)
return index.find(String(value))
}

/** Adds an item to the collection */
add(arg: Item | Item[]) {
/** Adds an item to the collection. Returns an effect. */
private effect_add(arg: Item | Item[]) {
const items = Array.isArray(arg) ? arg : [arg]

return E.gen(this, function* () {
Expand All @@ -134,10 +195,10 @@ export abstract class Collection<
})
}

/** Updates an item given an object containing its ID and one or more updated properties. */
update(item: { id: IdOf<Item> } & Partial<Item>) {
/** Updates an item given an object containing its ID and one or more updated properties. Returns an effect. */
private effect_update(item: { id: IdOf<Item> } & Partial<Item>) {
return E.gen(this, function* () {
const prevValue = yield* this.find(item.id)
const prevValue = yield* this.effect_find(item.id)
const newValue: Item = { ...prevValue, ...item }

// update the item in all indexes
Expand All @@ -155,10 +216,10 @@ export abstract class Collection<
})
}

/** Removes an item from a collection, given its ID. */
destroy(id: IdOf<Item>) {
/** Removes an item from a collection, given its ID. Returns an effect. */
private effect_destroy(id: IdOf<Item>) {
return E.gen(this, function* () {
const item = yield* this.find(id)
const item = yield* this.effect_find(id)

// remove the item from all indexes
for (const index of this.allIndexes) {
Expand All @@ -173,8 +234,8 @@ export abstract class Collection<
})
}

/** Removes all items from the collection. */
destroyAll() {
/** Removes all items from the collection. Returns an effect. */
private effect_destroyAll() {
return E.gen(this, function* () {
// remove all items from all indexes
for (const index of this.allIndexes) {
Expand All @@ -188,8 +249,6 @@ export abstract class Collection<
})
}

// PRIVATE

/** Returns an array containing all indexes in the collection */
private get allIndexes() {
return [
Expand All @@ -207,7 +266,7 @@ export abstract class Collection<

// create the index on demand
if (!(name in indexes)) {
const items = $(this.all())
const items = E.runSync(this.effect_all())
const index =
unique ?
new UniqueIndex(items, key, name) //
Expand Down
3 changes: 1 addition & 2 deletions app/context/Database/test/Collection.benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { LocalDate } from "@js-joda/core"
import { clients } from "data/clients"
import { contacts } from "data/contacts"
import { projects } from "data/projects"
import { $ } from "lib/Effect"
import { generateTimeEntries } from "lib/generateTimeEntries"
import { TimeEntryCollection } from "schema/TimeEntryCollection"
import { bench, describe } from "vitest"
Expand All @@ -17,7 +16,7 @@ const addEntries = (weekCount: number) => {
})

const collection = new TimeEntryCollection([])
$(collection.add(entries))
collection.add(entries)
}

describe("collection.add()", () => {
Expand Down
56 changes: 28 additions & 28 deletions app/context/Database/test/Collection.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { LocalDate } from "@js-joda/core"
import { contacts } from "data/contacts"
import { $, E, Either } from "lib/Effect"
import { E, Either } from "lib/Effect"
import { generateDones } from "lib/generateDones"
import { Contact } from "schema/Contact"
import { DoneEntry } from "schema/DoneEntry"
Expand Down Expand Up @@ -56,40 +56,40 @@ describe("Collection", () => {
describe("constructor", () => {
it("instantiates the collection", () => {
const { collection } = setup()
expect($(collection.all())).toHaveLength(14)
expect(collection.all()).toHaveLength(14)
})
})

describe("find", () => {
it("finds an item by ID", () => {
const { dones, collection } = setup()
const { id } = dones[0]
const item = $(collection.find(id))
const item = collection.find(id)
expect(item).toEqual(dones[0])
})

it("fails if an ID is not found", () => {
const { collection } = setup()

const result = collection.find("pizza").pipe(E.either, E.runSync)
const result = collection.effect.find("pizza").pipe(E.either, E.runSync)
expect(Either.isLeft(result)).toBe(true)
})

it("finds an item by a non-unique indexed property", () => {
const { collection } = setup()
const items = $(collection.findBy("contactId", alice.id))
const items = collection.findBy("contactId", alice.id)
expect(items).toHaveLength(7)
})

it("finds an item using an accessor function", () => {
const { collection } = setup()
const items = $(collection.findBy("week", "2024-11-03"))
const items = collection.findBy("week", "2024-11-03")
expect(items).toHaveLength(8)
})

it("finds an item by year", () => {
const { collection } = setup()
const items = $(collection.findBy("year", 2024))
const items = collection.findBy("year", 2024)
expect(items).toHaveLength(14)
})
})
Expand All @@ -102,16 +102,16 @@ describe("Collection", () => {
content: "new",
date: LocalDate.parse("2024-11-04"),
})
const result = $(collection.add(newDone))
const result = collection.add(newDone)
expect(result).toBe(newDone)
expect($(collection.all())).toHaveLength(15)
expect($(collection.findBy("contactId", alice.id))).toHaveLength(8)
expect($(collection.findBy("week", "2024-11-03"))).toHaveLength(9)
expect(collection.all()).toHaveLength(15)
expect(collection.findBy("contactId", alice.id)).toHaveLength(8)
expect(collection.findBy("week", "2024-11-03")).toHaveLength(9)
})

it("adds multiple items to the collection", () => {
const { collection } = setup()
expect($(collection.all())).toHaveLength(14)
expect(collection.all()).toHaveLength(14)

// generate a bunch of dones
const dones = generateDones({
Expand All @@ -121,59 +121,59 @@ describe("Collection", () => {
enthusiasm: 0.1,
contacts,
})
const result = $(collection.add(dones))
const result = collection.add(dones)
expect(result).toBe(dones)
expect($(collection.all())).toHaveLength(224)
expect(collection.all()).toHaveLength(224)
})
})

describe("update", () => {
it("updates a string value", () => {
const { collection } = setup()
const dones = $(collection.all())
const dones = collection.all()
const item = dones[0]
$(collection.update({ ...item, content: "updated" }))
const updated = $(collection.find(item.id))
collection.update({ ...item, content: "updated" })
const updated = collection.find(item.id)
expect(updated.content).toBe("updated")
})

it("updates a date value", () => {
const { collection } = setup()
const dones = $(collection.all())
const dones = collection.all()
const item = dones[0]
$(collection.update({ ...item, date: LocalDate.parse("2024-11-05") }))
collection.update({ ...item, date: LocalDate.parse("2024-11-05") })

const updated = $(collection.find(item.id))
const updated = collection.find(item.id)
expect(updated.date.toString()).toBe("2024-11-05")
})

it("updates an array value", () => {
const { collection } = setup()
const dones = $(collection.all())
const dones = collection.all()
const item = dones[0]
$(collection.update({ ...item, likes: [bob] }))
collection.update({ ...item, likes: [bob] })

const updated = $(collection.find(item.id))
const updated = collection.find(item.id)
expect(updated.likes).toEqual([bob])
})
})

describe("destroy", () => {
it("removes an item from the collection", () => {
const { collection } = setup()
const dones = $(collection.all())
const dones = collection.all()
const item = dones[0]
$(collection.destroy(item.id))
collection.destroy(item.id)

expect($(collection.all())).toHaveLength(13)
expect(collection.all()).toHaveLength(13)
})
})

describe("destroyAll", () => {
it("removes all items from the collection", () => {
const { collection } = setup()
$(collection.destroyAll())
expect($(collection.all())).toHaveLength(0)
collection.destroyAll()
expect(collection.all()).toHaveLength(0)
})
})
})
Loading
Loading