File
Blob: src/worker/objects/common/collection-proppatch.ts
| 1 | import { extractPropTextValue } from "@/worker/db/dav-collections"; |
| 2 | import { DAV_NS } from "@/worker/xml/namespaces"; |
| 3 | import type { |
| 4 | CollectionDomainError, |
| 5 | DomainCollection, |
| 6 | DomainConditionalInput, |
| 7 | DomainProppatchInput, |
| 8 | } from "@/worker/objects/common/collection-types"; |
| 9 | |
| 10 | export interface CollectionProppatchSpec< |
| 11 | TDb, |
| 12 | TTx, |
| 13 | TCollection extends DomainCollection, |
| 14 | TObject extends { id: string; etag: string }, |
| 15 | TError extends CollectionDomainError, |
| 16 | > { |
| 17 | collectionKind: string; |
| 18 | ensureDefaultCollection(db: TDb, subjectId: string, nowMs: number): TCollection; |
| 19 | ensureCollection(db: TDb, subjectId: string, nowMs: number, collectionName: string): TCollection | null; |
| 20 | objectByName(db: TDb, collectionId: string, objectName: string): TObject | undefined; |
| 21 | targetHref(collection: TCollection, kind: string, objectName?: string): string; |
| 22 | writePrecondition( |
| 23 | db: TDb, |
| 24 | input: DomainConditionalInput, |
| 25 | targetHref: string, |
| 26 | targetEtag: string | null, |
| 27 | ): TError | null; |
| 28 | transaction(db: TDb, callback: (tx: TTx) => void): void; |
| 29 | updateCollectionDisplayName(tx: TTx, collectionId: string, displayName: string, nowMs: number): void; |
| 30 | upsertDeadProp( |
| 31 | tx: TTx, |
| 32 | key: { resourceKind: string; resourceId: string; nsUri: string; localName: string }, |
| 33 | xmlValue: string, |
| 34 | ): void; |
| 35 | deleteDeadProp(tx: TTx, key: { resourceKind: string; resourceId: string; nsUri: string; localName: string }): void; |
| 36 | collectionNotFound(): TError; |
| 37 | resourceNotFound(): TError; |
| 38 | } |
| 39 | |
| 40 | export function proppatchCollectionDomain< |
| 41 | TDb, |
| 42 | TTx, |
| 43 | TCollection extends DomainCollection, |
| 44 | TObject extends { id: string; etag: string }, |
| 45 | TError extends CollectionDomainError, |
| 46 | >( |
| 47 | db: TDb, |
| 48 | input: DomainProppatchInput, |
| 49 | spec: CollectionProppatchSpec<TDb, TTx, TCollection, TObject, TError>, |
| 50 | ): { ok: true } | TError { |
| 51 | const collection = |
| 52 | input.kind === "home" |
| 53 | ? spec.ensureDefaultCollection(db, input.subjectId, input.nowMs) |
| 54 | : spec.ensureCollection(db, input.subjectId, input.nowMs, input.collectionName ?? ""); |
| 55 | if (!collection) return spec.collectionNotFound(); |
| 56 | |
| 57 | const resourceId = input.resourceId ?? resolveDomainResourceId(db, input, collection, spec); |
| 58 | if (!resourceId) return spec.resourceNotFound(); |
| 59 | |
| 60 | const object = input.kind === "object" ? spec.objectByName(db, collection.id, input.objectName ?? "") : undefined; |
| 61 | const precondition = spec.writePrecondition( |
| 62 | db, |
| 63 | input, |
| 64 | spec.targetHref(collection, input.kind, input.objectName), |
| 65 | object?.etag ?? null, |
| 66 | ); |
| 67 | if (precondition) return precondition; |
| 68 | |
| 69 | spec.transaction(db, (tx) => { |
| 70 | for (const instruction of input.instructions) { |
| 71 | if (instruction.kind === "set") { |
| 72 | if (isMappedDisplayName(input.kind, spec.collectionKind, instruction)) { |
| 73 | spec.updateCollectionDisplayName(tx, resourceId, extractPropTextValue(instruction.xmlValue), input.nowMs); |
| 74 | continue; |
| 75 | } |
| 76 | spec.upsertDeadProp( |
| 77 | tx, |
| 78 | { |
| 79 | resourceKind: input.kind, |
| 80 | resourceId, |
| 81 | nsUri: instruction.nsUri, |
| 82 | localName: instruction.localName, |
| 83 | }, |
| 84 | instruction.xmlValue, |
| 85 | ); |
| 86 | continue; |
| 87 | } |
| 88 | if (isMappedDisplayName(input.kind, spec.collectionKind, instruction)) { |
| 89 | spec.updateCollectionDisplayName(tx, resourceId, collection.name, input.nowMs); |
| 90 | continue; |
| 91 | } |
| 92 | spec.deleteDeadProp(tx, { |
| 93 | resourceKind: input.kind, |
| 94 | resourceId, |
| 95 | nsUri: instruction.nsUri, |
| 96 | localName: instruction.localName, |
| 97 | }); |
| 98 | } |
| 99 | }); |
| 100 | |
| 101 | return { ok: true }; |
| 102 | } |
| 103 | |
| 104 | function resolveDomainResourceId< |
| 105 | TDb, |
| 106 | TTx, |
| 107 | TCollection extends DomainCollection, |
| 108 | TObject extends { id: string; etag: string }, |
| 109 | TError extends CollectionDomainError, |
| 110 | >( |
| 111 | db: TDb, |
| 112 | input: DomainProppatchInput, |
| 113 | collection: TCollection, |
| 114 | spec: CollectionProppatchSpec<TDb, TTx, TCollection, TObject, TError>, |
| 115 | ): string | null { |
| 116 | if (input.kind === "home") return "home"; |
| 117 | if (input.kind === spec.collectionKind) return collection.id; |
| 118 | return spec.objectByName(db, collection.id, input.objectName ?? "")?.id ?? null; |
| 119 | } |
| 120 | |
| 121 | function isMappedDisplayName( |
| 122 | kind: string, |
| 123 | collectionKind: string, |
| 124 | prop: { nsUri: string; localName: string }, |
| 125 | ): boolean { |
| 126 | return kind === collectionKind && prop.nsUri === DAV_NS && prop.localName === "displayname"; |
| 127 | } |