@thomasfosterau/pulpit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1272 -2
- package/dist/blobs.d.ts +249 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +193 -0
- package/dist/blobs.js.map +1 -0
- package/dist/budgets.d.ts +255 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +211 -0
- package/dist/budgets.js.map +1 -0
- package/dist/cache.d.ts +154 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +118 -0
- package/dist/cache.js.map +1 -0
- package/dist/collections.d.ts +242 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +215 -0
- package/dist/collections.js.map +1 -0
- package/dist/comments.d.ts +212 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +192 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +231 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +204 -0
- package/dist/content.js.map +1 -0
- package/dist/documents.d.ts +184 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +190 -0
- package/dist/documents.js.map +1 -0
- package/dist/edges.d.ts +645 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +558 -0
- package/dist/edges.js.map +1 -0
- package/dist/filter.d.ts +461 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +387 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/filter-columns.d.ts +8 -0
- package/dist/internal/filter-columns.d.ts.map +1 -0
- package/dist/internal/filter-columns.js +43 -0
- package/dist/internal/filter-columns.js.map +1 -0
- package/dist/internal/list-order.d.ts +26 -0
- package/dist/internal/list-order.d.ts.map +1 -0
- package/dist/internal/list-order.js +92 -0
- package/dist/internal/list-order.js.map +1 -0
- package/dist/internal/owned-columns.d.ts +93 -0
- package/dist/internal/owned-columns.d.ts.map +1 -0
- package/dist/internal/owned-columns.js +98 -0
- package/dist/internal/owned-columns.js.map +1 -0
- package/dist/internal/plain-rows.d.ts +32 -0
- package/dist/internal/plain-rows.d.ts.map +1 -0
- package/dist/internal/plain-rows.js +32 -0
- package/dist/internal/plain-rows.js.map +1 -0
- package/dist/internal/reference-columns.d.ts +80 -0
- package/dist/internal/reference-columns.d.ts.map +1 -0
- package/dist/internal/reference-columns.js +102 -0
- package/dist/internal/reference-columns.js.map +1 -0
- package/dist/moderation.d.ts +392 -0
- package/dist/moderation.d.ts.map +1 -0
- package/dist/moderation.js +375 -0
- package/dist/moderation.js.map +1 -0
- package/dist/paths.d.ts +204 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +165 -0
- package/dist/paths.js.map +1 -0
- package/dist/proposals.d.ts +523 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/proposals.js +505 -0
- package/dist/proposals.js.map +1 -0
- package/dist/publication.d.ts +148 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/publication.js +183 -0
- package/dist/publication.js.map +1 -0
- package/dist/purge.d.ts +224 -0
- package/dist/purge.d.ts.map +1 -0
- package/dist/purge.js +151 -0
- package/dist/purge.js.map +1 -0
- package/dist/reactions.d.ts +246 -0
- package/dist/reactions.d.ts.map +1 -0
- package/dist/reactions.js +183 -0
- package/dist/reactions.js.map +1 -0
- package/dist/registry.d.ts +316 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +305 -0
- package/dist/registry.js.map +1 -0
- package/dist/search.d.ts +366 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +273 -0
- package/dist/search.js.map +1 -0
- package/dist/social.d.ts +311 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +205 -0
- package/dist/social.js.map +1 -0
- package/dist/subtype.d.ts +1613 -0
- package/dist/subtype.d.ts.map +1 -0
- package/dist/subtype.js +1300 -0
- package/dist/subtype.js.map +1 -0
- package/dist/testing.d.ts +675 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1022 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-chain.d.ts +702 -0
- package/dist/version-chain.d.ts.map +1 -0
- package/dist/version-chain.js +489 -0
- package/dist/version-chain.js.map +1 -0
- package/dist/working-copy.d.ts +116 -0
- package/dist/working-copy.d.ts.map +1 -0
- package/dist/working-copy.js +95 -0
- package/dist/working-copy.js.map +1 -0
- package/package.json +145 -3
- package/src/blobs.ts +384 -0
- package/src/budgets.ts +338 -0
- package/src/cache.ts +218 -0
- package/src/collections.ts +427 -0
- package/src/comments.ts +392 -0
- package/src/content.ts +238 -0
- package/src/documents.ts +284 -0
- package/src/edges.ts +1185 -0
- package/src/filter.ts +523 -0
- package/src/index.ts +153 -0
- package/src/internal/filter-columns.ts +50 -0
- package/src/internal/list-order.ts +117 -0
- package/src/internal/owned-columns.ts +140 -0
- package/src/internal/plain-rows.ts +54 -0
- package/src/internal/reference-columns.ts +156 -0
- package/src/moderation.ts +728 -0
- package/src/paths.ts +267 -0
- package/src/proposals.ts +1014 -0
- package/src/publication.ts +225 -0
- package/src/purge.ts +313 -0
- package/src/reactions.ts +347 -0
- package/src/registry.ts +524 -0
- package/src/search.ts +540 -0
- package/src/social.ts +469 -0
- package/src/subtype.ts +3095 -0
- package/src/testing.ts +1873 -0
- package/src/version-chain.ts +1093 -0
- package/src/working-copy.ts +192 -0
package/dist/purge.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Purge**: erasure, the one destructive path through a node's history
|
|
3
|
+
* (pulpit#58).
|
|
4
|
+
*
|
|
5
|
+
* A tombstone hides a node and keeps every version of its content, readable
|
|
6
|
+
* at the view-deleted rung. Where that content is personal data and the law
|
|
7
|
+
* requires it erased rather than hidden, {@link definePurge}'s `purge`
|
|
8
|
+
* hard-deletes it. It removes the node's whole chain, every row the kernel's
|
|
9
|
+
* other seams keep for the node, and the documents and file bytes no other
|
|
10
|
+
* version references any longer.
|
|
11
|
+
*
|
|
12
|
+
* ## The model (owner decision on pulpit#58, 2026-10-14)
|
|
13
|
+
*
|
|
14
|
+
* - **The whole chain goes.** Every version row on every level of the
|
|
15
|
+
* node's chain is deleted, with its identity row. It is the one sanctioned
|
|
16
|
+
* exception to the append-only chain (`VERSION-CHAIN-001/002`, SECURITY.md):
|
|
17
|
+
* nothing else deletes or rewrites a version row. With the chain go the
|
|
18
|
+
* node's working copies, proposals, publication facts, path claims, reports
|
|
19
|
+
* and every edge with the node at an end: rows that name a node that no
|
|
20
|
+
* longer exists.
|
|
21
|
+
* - **Shared content is deleted only when nothing else references it.**
|
|
22
|
+
* Documents (`./documents`) and bytes (`./blobs`) are shared by hash. A
|
|
23
|
+
* purge drops the node's references; a document or a blob is deleted only
|
|
24
|
+
* when no version of any other node still references its hash, so
|
|
25
|
+
* identical content another node uses survives. A deleted document is the
|
|
26
|
+
* one exception to `DOC-001` (a stored document is never rewritten).
|
|
27
|
+
* - **Bytes are deleted after the commit**, as `./blobs` writes them after
|
|
28
|
+
* the commit: the chain's erasure and the document deletes run in
|
|
29
|
+
* `transact`, the caller's write boundary, and the blob store is told only
|
|
30
|
+
* once it has returned. A failed byte delete never fails the purge, which
|
|
31
|
+
* has committed: the report lists the bytes left behind
|
|
32
|
+
* ({@link PurgeReport.bytesLeft}) for a retry through `BlobStore.delete`.
|
|
33
|
+
* - **A dedicated rung.** `purge` runs at the rung the definition's
|
|
34
|
+
* `canPurge` names, checked before anything is read, so a caller below it
|
|
35
|
+
* learns nothing about any id (`Unauthorized` / `Forbidden` for every id
|
|
36
|
+
* alike). At the rung, an unknown or already purged id fails
|
|
37
|
+
* `NodeNotFound`. A live node purges as a tombstoned one does: erasure is
|
|
38
|
+
* one step, never "delete, then purge".
|
|
39
|
+
*
|
|
40
|
+
* ## The seam
|
|
41
|
+
*
|
|
42
|
+
* The erasure itself is the application's {@link PurgeStore}: it knows
|
|
43
|
+
* which tables hold rows keyed to a node and which columns carry a document
|
|
44
|
+
* or content hash. The contract is stated in the package README → Seams.
|
|
45
|
+
*
|
|
46
|
+
* @copyright Thomas Foster
|
|
47
|
+
* @module pulpit/purge
|
|
48
|
+
* @since 0.1.0
|
|
49
|
+
*/
|
|
50
|
+
import * as Arr from "effect/Array";
|
|
51
|
+
import * as Effect from "effect/Effect";
|
|
52
|
+
import * as Exit from "effect/Exit";
|
|
53
|
+
import * as Option from "effect/Option";
|
|
54
|
+
import { blobKey } from "./blobs.js";
|
|
55
|
+
import { Forbidden, Unauthorized, } from "./subtype.js";
|
|
56
|
+
import { NodeNotFound } from "./version-chain.js";
|
|
57
|
+
/**
|
|
58
|
+
* Bind a leaf's purge. `purge(id)` checks the purge rung, then, inside
|
|
59
|
+
* `transact`, erases the node through the {@link PurgeStore}, deletes the
|
|
60
|
+
* documents nothing else references, and reads which of its bytes nothing
|
|
61
|
+
* else references; once `transact` has returned it evicts the cache and
|
|
62
|
+
* deletes those bytes. A failed byte delete is logged and reported in
|
|
63
|
+
* {@link PurgeReport.bytesLeft}, never a failure.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* import { deepStrictEqual } from "node:assert";
|
|
68
|
+
* import * as Effect from "effect/Effect";
|
|
69
|
+
* import * as HashMap from "effect/HashMap";
|
|
70
|
+
* import * as Option from "effect/Option";
|
|
71
|
+
* import { defineVersionChain } from "@thomasfosterau/pulpit/version-chain";
|
|
72
|
+
* import { definePurge } from "@thomasfosterau/pulpit/purge";
|
|
73
|
+
* import {
|
|
74
|
+
* makeInMemoryChainStore,
|
|
75
|
+
* makeInMemoryDocumentStore,
|
|
76
|
+
* makeInMemoryPurgeStore,
|
|
77
|
+
* } from "@thomasfosterau/pulpit/testing";
|
|
78
|
+
*
|
|
79
|
+
* const chain = makeInMemoryChainStore();
|
|
80
|
+
* const documents = makeInMemoryDocumentStore();
|
|
81
|
+
* const notes = defineVersionChain("notes", chain);
|
|
82
|
+
* const purge = definePurge({
|
|
83
|
+
* type: "notes",
|
|
84
|
+
* chain,
|
|
85
|
+
* store: makeInMemoryPurgeStore({ chains: [chain], documents, documentColumns: ["body"] }),
|
|
86
|
+
* viewer: Effect.succeed(Option.some({ id: "u-admin", role: "admin" })),
|
|
87
|
+
* canPurge: (account) => account.role === "admin",
|
|
88
|
+
* transact: (effect) => effect,
|
|
89
|
+
* });
|
|
90
|
+
*
|
|
91
|
+
* const report = await Effect.runPromise(
|
|
92
|
+
* Effect.gen(function* () {
|
|
93
|
+
* const { hash } = yield* documents.put({ text: "personal data" });
|
|
94
|
+
* const { id } = yield* notes.create({
|
|
95
|
+
* id: Option.none(),
|
|
96
|
+
* state: { body: hash },
|
|
97
|
+
* actorId: Option.none(),
|
|
98
|
+
* });
|
|
99
|
+
* return yield* purge.purge(id);
|
|
100
|
+
* }),
|
|
101
|
+
* );
|
|
102
|
+
*
|
|
103
|
+
* // The chain and its only document (a hash no other version references) are gone.
|
|
104
|
+
* deepStrictEqual(chain.rows(), []);
|
|
105
|
+
* deepStrictEqual(HashMap.size(documents.stored()), 0);
|
|
106
|
+
* deepStrictEqual(report.documents.length, 1);
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* @since 0.1.0
|
|
110
|
+
*/
|
|
111
|
+
export const definePurge = (definition) => {
|
|
112
|
+
const { type, chain, store, transact } = definition;
|
|
113
|
+
/** The purge rung, checked before any row is read. */
|
|
114
|
+
const gate = Effect.flatMap(definition.viewer, (viewer) => Option.match(viewer, {
|
|
115
|
+
onNone: () => Effect.fail(new Unauthorized()),
|
|
116
|
+
onSome: (account) => definition.canPurge(account) ? Effect.succeed(account) : Effect.fail(new Forbidden()),
|
|
117
|
+
}));
|
|
118
|
+
/** The erasure, in the caller's transaction: what it deleted and which bytes it freed. */
|
|
119
|
+
const erase = (id) => Effect.gen(function* () {
|
|
120
|
+
const head = yield* chain.head(id);
|
|
121
|
+
if (Option.isNone(head))
|
|
122
|
+
return yield* new NodeNotFound({ type, id });
|
|
123
|
+
const erased = yield* store.eraseNode(id);
|
|
124
|
+
const documents = yield* store.deleteUnreferencedDocuments(Arr.dedupe(erased.documents));
|
|
125
|
+
const freed = yield* store.unreferencedBlobs(Arr.dedupe(erased.blobs));
|
|
126
|
+
return { head: head.value.version, documents, freed };
|
|
127
|
+
});
|
|
128
|
+
/** Delete one blob's bytes after the commit: whether they are gone. */
|
|
129
|
+
const deleteBytes = (hash) => definition.blobs === undefined
|
|
130
|
+
? Effect.succeed(false)
|
|
131
|
+
: Effect.exit(definition.blobs.delete(blobKey(hash))).pipe(Effect.tap((exit) => Exit.isFailure(exit)
|
|
132
|
+
? Effect.logWarning(`${type}: bytes ${hash} left after purge`, exit.cause)
|
|
133
|
+
: Effect.void), Effect.map(Exit.isSuccess));
|
|
134
|
+
const purge = Effect.fn(`${type}.purge`)(function* (id) {
|
|
135
|
+
yield* gate;
|
|
136
|
+
const erased = yield* transact(erase(id));
|
|
137
|
+
if (definition.cache !== undefined)
|
|
138
|
+
yield* definition.cache.delete(id, erased.head + 1n);
|
|
139
|
+
const deleted = yield* Effect.forEach(erased.freed, (hash) => Effect.map(deleteBytes(hash), (gone) => [hash, gone]), { concurrency: 1 });
|
|
140
|
+
const gone = (wanted) => Arr.map(Arr.filter(deleted, ([, deletedNow]) => deletedNow === wanted), ([hash]) => hash);
|
|
141
|
+
const report = {
|
|
142
|
+
id,
|
|
143
|
+
documents: erased.documents,
|
|
144
|
+
blobs: gone(true),
|
|
145
|
+
bytesLeft: gone(false),
|
|
146
|
+
};
|
|
147
|
+
return report;
|
|
148
|
+
});
|
|
149
|
+
return { type, purge };
|
|
150
|
+
};
|
|
151
|
+
//# sourceMappingURL=purge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"purge.js","sourceRoot":"","sources":["../src/purge.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Reactions** (pulpit#196) — one reaction per account per node, a kind
|
|
3
|
+
* from the family's own vocabulary, behind the target's read gate, with a
|
|
4
|
+
* tally per kind for a page of nodes in one grouped read.
|
|
5
|
+
*
|
|
6
|
+
* The behavioural spec is gardeneria's `social.reactions` table
|
|
7
|
+
* (`packages/database/src/tables/reactions.ts`), its model
|
|
8
|
+
* (`packages/models/src/models/reactions.ts`) and its operations
|
|
9
|
+
* (`apps/api/src/operations/reactions.ts`), with their tests.
|
|
10
|
+
*
|
|
11
|
+
* ## The model (owner decision on pulpit#196)
|
|
12
|
+
*
|
|
13
|
+
* - **A reaction is a fact beside the node**, like an edge: no version
|
|
14
|
+
* chain and no history. The store keys it `(node, account)`, so an
|
|
15
|
+
* account has at most one reaction per node: reacting again with another
|
|
16
|
+
* kind replaces it, and unreacting hard-deletes it.
|
|
17
|
+
* - **The kinds are the family's.** {@link defineReactions} takes the
|
|
18
|
+
* vocabulary (gardeneria's `like | celebrate | helpful | curious |
|
|
19
|
+
* sympathise`) and throws {@link ReactionDeclarationError} at import for a
|
|
20
|
+
* malformed one; the kernel holds no list of kinds.
|
|
21
|
+
* - **The target's read gate applies to every operation**
|
|
22
|
+
* (`SEC-AUTH-ENUM-001`): reacting to, or unreacting from, a node the
|
|
23
|
+
* viewer can't read fails `NodeNotFound`, never `Forbidden`; a tally
|
|
24
|
+
* counts only nodes the viewer can read; and the viewer's own list omits
|
|
25
|
+
* a reaction whose node has since been hidden from them.
|
|
26
|
+
* - **Account erasure** (pulpit#207) deletes a member's reactions through
|
|
27
|
+
* {@link ReactionStore.eraseAccount}, which the erasure operation calls;
|
|
28
|
+
* no reaction operation exposes it.
|
|
29
|
+
*
|
|
30
|
+
* Mute and block (pulpit#195) suppress interaction only, which a family
|
|
31
|
+
* enforces with its own gate around `react` once the social graph lands;
|
|
32
|
+
* a tally counts every account's reactions.
|
|
33
|
+
*
|
|
34
|
+
* @copyright Thomas Foster
|
|
35
|
+
* @module pulpit/reactions
|
|
36
|
+
* @since 0.1.0
|
|
37
|
+
*/
|
|
38
|
+
import * as Effect from "effect/Effect";
|
|
39
|
+
import * as HashMap from "effect/HashMap";
|
|
40
|
+
import * as Option from "effect/Option";
|
|
41
|
+
import * as Schema from "effect/Schema";
|
|
42
|
+
import { type SubtypeOps, type Unauthorized, type Viewer } from "./subtype.ts";
|
|
43
|
+
import type { NodeNotFound } from "./version-chain.ts";
|
|
44
|
+
/**
|
|
45
|
+
* A family's reaction vocabulary, as {@link defineReactions} validated it.
|
|
46
|
+
*
|
|
47
|
+
* @since 0.1.0
|
|
48
|
+
*/
|
|
49
|
+
export interface ReactionKinds {
|
|
50
|
+
readonly kinds: ReadonlyArray<string>;
|
|
51
|
+
}
|
|
52
|
+
declare const ReactionDeclarationError_base: Schema.Class<ReactionDeclarationError, Schema.TaggedStruct<"ReactionDeclarationError", {
|
|
53
|
+
readonly message: Schema.String;
|
|
54
|
+
}>, import("effect/Cause").YieldableError & {
|
|
55
|
+
readonly brand: unique symbol;
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* Thrown by {@link reactionKinds} at import for a malformed vocabulary: no
|
|
59
|
+
* kind, an empty kind or a kind named twice. Never an Effect failure: no
|
|
60
|
+
* runtime exists when a family is declared.
|
|
61
|
+
*
|
|
62
|
+
* @since 0.1.0
|
|
63
|
+
*/
|
|
64
|
+
export declare class ReactionDeclarationError extends ReactionDeclarationError_base {
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Declare a family's reaction vocabulary. Throws
|
|
68
|
+
* {@link ReactionDeclarationError} for no kind, an empty kind or a repeated
|
|
69
|
+
* one.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* import { deepStrictEqual } from "node:assert";
|
|
74
|
+
* import { reactionKinds } from "@thomasfosterau/pulpit/reactions";
|
|
75
|
+
*
|
|
76
|
+
* const kinds = reactionKinds(["like", "celebrate", "helpful"]);
|
|
77
|
+
* deepStrictEqual(kinds.kinds, ["like", "celebrate", "helpful"]);
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* @since 0.1.0
|
|
81
|
+
*/
|
|
82
|
+
export declare const reactionKinds: (kinds: ReadonlyArray<string>) => ReactionKinds;
|
|
83
|
+
declare const InvalidReactionKind_base: Schema.Class<InvalidReactionKind, Schema.TaggedStruct<"InvalidReactionKind", {
|
|
84
|
+
readonly type: Schema.String;
|
|
85
|
+
readonly kind: Schema.String;
|
|
86
|
+
}>, import("effect/Cause").YieldableError & {
|
|
87
|
+
readonly brand: unique symbol;
|
|
88
|
+
}>;
|
|
89
|
+
/**
|
|
90
|
+
* A kind the family doesn't declare: the wire's 422.
|
|
91
|
+
*
|
|
92
|
+
* @since 0.1.0
|
|
93
|
+
*/
|
|
94
|
+
export declare class InvalidReactionKind extends InvalidReactionKind_base {
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* One stored reaction.
|
|
98
|
+
*
|
|
99
|
+
* @since 0.1.0
|
|
100
|
+
*/
|
|
101
|
+
export interface ReactionRow {
|
|
102
|
+
readonly nodeId: string;
|
|
103
|
+
readonly accountId: string;
|
|
104
|
+
readonly kind: string;
|
|
105
|
+
readonly reactedAt: Date;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The reaction table an application supplies (gardeneria's
|
|
109
|
+
* `social.reactions`), keyed `(node_id, account_id)` with both foreign keys
|
|
110
|
+
* cascading. A store seam: no policy, never opens a transaction, fails only
|
|
111
|
+
* with its `E`. See the package README → Seams.
|
|
112
|
+
*
|
|
113
|
+
* @since 0.1.0
|
|
114
|
+
*/
|
|
115
|
+
export interface ReactionStore<E, R> {
|
|
116
|
+
/**
|
|
117
|
+
* Insert or replace the account's reaction to the node
|
|
118
|
+
* (`INSERT … ON CONFLICT (node_id, account_id) DO UPDATE`), returning the
|
|
119
|
+
* kind it replaced, or none for a first reaction.
|
|
120
|
+
*/
|
|
121
|
+
readonly upsert: (row: ReactionRow) => Effect.Effect<Option.Option<string>, E, R>;
|
|
122
|
+
/** Delete the account's reaction to the node; whether a row went. */
|
|
123
|
+
readonly delete: (nodeId: string, accountId: string) => Effect.Effect<boolean, E, R>;
|
|
124
|
+
/**
|
|
125
|
+
* One `GROUP BY node_id, kind` read over `nodeIds` (never empty, no
|
|
126
|
+
* duplicate, at most `MAX_SET_VALUES`): each node's count per kind. A node
|
|
127
|
+
* with no reaction has no key.
|
|
128
|
+
*/
|
|
129
|
+
readonly tally: (nodeIds: ReadonlyArray<string>) => Effect.Effect<HashMap.HashMap<string, {
|
|
130
|
+
readonly [kind: string]: number;
|
|
131
|
+
}>, E, R>;
|
|
132
|
+
/** The account's own reactions, newest first (`reacted_at DESC, node_id`), one page. */
|
|
133
|
+
readonly byAccount: (query: {
|
|
134
|
+
readonly accountId: string;
|
|
135
|
+
readonly limit: number;
|
|
136
|
+
readonly offset: number;
|
|
137
|
+
}) => Effect.Effect<ReadonlyArray<ReactionRow>, E, R>;
|
|
138
|
+
/** Delete every reaction the account made (account erasure, pulpit#207); how many went. */
|
|
139
|
+
readonly eraseAccount: (accountId: string) => Effect.Effect<number, E, R>;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* One family's reactions: the kinds, the target leaf's read gate and the
|
|
143
|
+
* store.
|
|
144
|
+
*
|
|
145
|
+
* @since 0.1.0
|
|
146
|
+
*/
|
|
147
|
+
export interface ReactionsDefinition<Resource, E, R> {
|
|
148
|
+
/** The target leaf's wire type, named in failures. */
|
|
149
|
+
readonly type: string;
|
|
150
|
+
readonly kinds: ReactionKinds;
|
|
151
|
+
/** The target leaf's gated reads: `get` gates a reaction, `getMany` a tally and the viewer's list. */
|
|
152
|
+
readonly target: Pick<SubtypeOps<Resource, E, R>, "get" | "getMany">;
|
|
153
|
+
readonly reactions: ReactionStore<E, R>;
|
|
154
|
+
/** The request's viewer. */
|
|
155
|
+
readonly viewer: Effect.Effect<Viewer, never, R>;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* A node's tally: its count per declared kind (an undeclared kind a store
|
|
159
|
+
* still holds is not counted), and the total.
|
|
160
|
+
*
|
|
161
|
+
* @since 0.1.0
|
|
162
|
+
*/
|
|
163
|
+
export interface Tally {
|
|
164
|
+
readonly counts: {
|
|
165
|
+
readonly [kind: string]: number;
|
|
166
|
+
};
|
|
167
|
+
readonly total: number;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The operations {@link defineReactions} produces.
|
|
171
|
+
*
|
|
172
|
+
* @since 0.1.0
|
|
173
|
+
*/
|
|
174
|
+
export interface ReactionOps<E, R> {
|
|
175
|
+
/**
|
|
176
|
+
* React to `nodeId` with `kind`, replacing the viewer's earlier reaction
|
|
177
|
+
* to it. Fails `Unauthorized` when anonymous, {@link InvalidReactionKind}
|
|
178
|
+
* for an undeclared kind, and `NodeNotFound` when the viewer can't read
|
|
179
|
+
* the node (`SEC-AUTH-ENUM-001`). Returns the kind it replaced, if any.
|
|
180
|
+
*/
|
|
181
|
+
readonly react: (nodeId: string, kind: string) => Effect.Effect<{
|
|
182
|
+
readonly replaced: Option.Option<string>;
|
|
183
|
+
}, Unauthorized | InvalidReactionKind | NodeNotFound | E, R>;
|
|
184
|
+
/**
|
|
185
|
+
* Remove the viewer's reaction to `nodeId`; whether there was one. Fails
|
|
186
|
+
* `Unauthorized` when anonymous and `NodeNotFound` when the viewer can't
|
|
187
|
+
* read the node.
|
|
188
|
+
*/
|
|
189
|
+
readonly unreact: (nodeId: string) => Effect.Effect<{
|
|
190
|
+
readonly removed: boolean;
|
|
191
|
+
}, Unauthorized | NodeNotFound | E, R>;
|
|
192
|
+
/**
|
|
193
|
+
* The tally of every node of `nodeIds` the viewer can read, in one
|
|
194
|
+
* `getMany` and one grouped store read (`PERF-DB-001`): each readable node
|
|
195
|
+
* has a key (a zero tally when unreacted), a hidden or missing one has
|
|
196
|
+
* none. More than `MAX_SET_VALUES` distinct ids reads nothing and answers
|
|
197
|
+
* an empty map (`SEC-INPUT-003`).
|
|
198
|
+
*/
|
|
199
|
+
readonly tally: (nodeIds: ReadonlyArray<string>) => Effect.Effect<HashMap.HashMap<string, Tally>, E, R>;
|
|
200
|
+
/**
|
|
201
|
+
* One page of the viewer's own reactions, newest first, the limit clamped
|
|
202
|
+
* to `[1, MAX_PAGE_LIMIT]`; a reaction whose node the viewer can no
|
|
203
|
+
* longer read is omitted, so a page may hold fewer rows than the limit.
|
|
204
|
+
* Fails `Unauthorized` when anonymous.
|
|
205
|
+
*/
|
|
206
|
+
readonly mine: (request: {
|
|
207
|
+
readonly limit: number;
|
|
208
|
+
readonly offset: number;
|
|
209
|
+
}) => Effect.Effect<ReadonlyArray<ReactionRow>, Unauthorized | E, R>;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Bind one family's reaction operations. Every operation is traced as
|
|
213
|
+
* `${type}.reactions.<operation>`, and the writes run in the caller's
|
|
214
|
+
* transaction.
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```ts
|
|
218
|
+
* import { deepStrictEqual } from "node:assert";
|
|
219
|
+
* import * as Effect from "effect/Effect";
|
|
220
|
+
* import * as HashMap from "effect/HashMap";
|
|
221
|
+
* import * as Option from "effect/Option";
|
|
222
|
+
* import { defineReactions, reactionKinds } from "@thomasfosterau/pulpit/reactions";
|
|
223
|
+
* import { makeInMemoryReactionStore } from "@thomasfosterau/pulpit/testing";
|
|
224
|
+
*
|
|
225
|
+
* const reactions = defineReactions({
|
|
226
|
+
* type: "posts",
|
|
227
|
+
* kinds: reactionKinds(["like", "celebrate"]),
|
|
228
|
+
* // A target leaf where every node is readable; in an application, its `defineSubtype` operations.
|
|
229
|
+
* target: {
|
|
230
|
+
* get: (id) => Effect.succeed(id),
|
|
231
|
+
* getMany: (ids) => Effect.succeed(HashMap.fromIterable(ids.map((id) => [id, id] as const))),
|
|
232
|
+
* },
|
|
233
|
+
* reactions: makeInMemoryReactionStore(),
|
|
234
|
+
* viewer: Effect.succeedSome({ id: "u-1", role: "user" }),
|
|
235
|
+
* });
|
|
236
|
+
* const tally = Effect.runSync(
|
|
237
|
+
* Effect.andThen(reactions.react("post-1", "like"), reactions.tally(["post-1"])),
|
|
238
|
+
* );
|
|
239
|
+
* deepStrictEqual(Option.map(HashMap.get(tally, "post-1"), (t) => t.total), Option.some(1));
|
|
240
|
+
* ```
|
|
241
|
+
*
|
|
242
|
+
* @since 0.1.0
|
|
243
|
+
*/
|
|
244
|
+
export declare const defineReactions: <Resource, E, R>(definition: ReactionsDefinition<Resource, E, R>) => ReactionOps<E, R>;
|
|
245
|
+
export {};
|
|
246
|
+
//# sourceMappingURL=reactions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reactions.d.ts","sourceRoot":"","sources":["../src/reactions.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Reactions** (pulpit#196) — one reaction per account per node, a kind
|
|
3
|
+
* from the family's own vocabulary, behind the target's read gate, with a
|
|
4
|
+
* tally per kind for a page of nodes in one grouped read.
|
|
5
|
+
*
|
|
6
|
+
* The behavioural spec is gardeneria's `social.reactions` table
|
|
7
|
+
* (`packages/database/src/tables/reactions.ts`), its model
|
|
8
|
+
* (`packages/models/src/models/reactions.ts`) and its operations
|
|
9
|
+
* (`apps/api/src/operations/reactions.ts`), with their tests.
|
|
10
|
+
*
|
|
11
|
+
* ## The model (owner decision on pulpit#196)
|
|
12
|
+
*
|
|
13
|
+
* - **A reaction is a fact beside the node**, like an edge: no version
|
|
14
|
+
* chain and no history. The store keys it `(node, account)`, so an
|
|
15
|
+
* account has at most one reaction per node: reacting again with another
|
|
16
|
+
* kind replaces it, and unreacting hard-deletes it.
|
|
17
|
+
* - **The kinds are the family's.** {@link defineReactions} takes the
|
|
18
|
+
* vocabulary (gardeneria's `like | celebrate | helpful | curious |
|
|
19
|
+
* sympathise`) and throws {@link ReactionDeclarationError} at import for a
|
|
20
|
+
* malformed one; the kernel holds no list of kinds.
|
|
21
|
+
* - **The target's read gate applies to every operation**
|
|
22
|
+
* (`SEC-AUTH-ENUM-001`): reacting to, or unreacting from, a node the
|
|
23
|
+
* viewer can't read fails `NodeNotFound`, never `Forbidden`; a tally
|
|
24
|
+
* counts only nodes the viewer can read; and the viewer's own list omits
|
|
25
|
+
* a reaction whose node has since been hidden from them.
|
|
26
|
+
* - **Account erasure** (pulpit#207) deletes a member's reactions through
|
|
27
|
+
* {@link ReactionStore.eraseAccount}, which the erasure operation calls;
|
|
28
|
+
* no reaction operation exposes it.
|
|
29
|
+
*
|
|
30
|
+
* Mute and block (pulpit#195) suppress interaction only, which a family
|
|
31
|
+
* enforces with its own gate around `react` once the social graph lands;
|
|
32
|
+
* a tally counts every account's reactions.
|
|
33
|
+
*
|
|
34
|
+
* @copyright Thomas Foster
|
|
35
|
+
* @module pulpit/reactions
|
|
36
|
+
* @since 0.1.0
|
|
37
|
+
*/
|
|
38
|
+
import * as Arr from "effect/Array";
|
|
39
|
+
import * as Effect from "effect/Effect";
|
|
40
|
+
import * as HashMap from "effect/HashMap";
|
|
41
|
+
import * as Option from "effect/Option";
|
|
42
|
+
import * as Record from "effect/Record";
|
|
43
|
+
import * as Schema from "effect/Schema";
|
|
44
|
+
import { MAX_SET_VALUES } from "./filter.js";
|
|
45
|
+
import { nowDate, signedIn as signedInAs } from "./internal/plain-rows.js";
|
|
46
|
+
import { MAX_PAGE_LIMIT } from "./subtype.js";
|
|
47
|
+
/**
|
|
48
|
+
* Thrown by {@link reactionKinds} at import for a malformed vocabulary: no
|
|
49
|
+
* kind, an empty kind or a kind named twice. Never an Effect failure: no
|
|
50
|
+
* runtime exists when a family is declared.
|
|
51
|
+
*
|
|
52
|
+
* @since 0.1.0
|
|
53
|
+
*/
|
|
54
|
+
export class ReactionDeclarationError extends Schema.TaggedError()("ReactionDeclarationError", { message: Schema.String }) {
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Declare a family's reaction vocabulary. Throws
|
|
58
|
+
* {@link ReactionDeclarationError} for no kind, an empty kind or a repeated
|
|
59
|
+
* one.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* import { deepStrictEqual } from "node:assert";
|
|
64
|
+
* import { reactionKinds } from "@thomasfosterau/pulpit/reactions";
|
|
65
|
+
*
|
|
66
|
+
* const kinds = reactionKinds(["like", "celebrate", "helpful"]);
|
|
67
|
+
* deepStrictEqual(kinds.kinds, ["like", "celebrate", "helpful"]);
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @since 0.1.0
|
|
71
|
+
*/
|
|
72
|
+
export const reactionKinds = (kinds) => {
|
|
73
|
+
if (!Arr.isReadonlyArrayNonEmpty(kinds)) {
|
|
74
|
+
throw new ReactionDeclarationError({ message: "reactions declare no kind" });
|
|
75
|
+
}
|
|
76
|
+
if (kinds.some((kind) => kind === "")) {
|
|
77
|
+
throw new ReactionDeclarationError({ message: "a reaction kind is empty" });
|
|
78
|
+
}
|
|
79
|
+
const repeated = Arr.findFirst(kinds, (kind, index) => kinds.slice(0, index).includes(kind));
|
|
80
|
+
if (Option.isSome(repeated)) {
|
|
81
|
+
throw new ReactionDeclarationError({
|
|
82
|
+
message: `the reaction kind '${repeated.value}' is named twice`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { kinds: [...kinds] };
|
|
86
|
+
};
|
|
87
|
+
// ── Errors ────────────────────────────────────────────────────────────
|
|
88
|
+
/**
|
|
89
|
+
* A kind the family doesn't declare: the wire's 422.
|
|
90
|
+
*
|
|
91
|
+
* @since 0.1.0
|
|
92
|
+
*/
|
|
93
|
+
export class InvalidReactionKind extends Schema.TaggedError()("InvalidReactionKind", { type: Schema.String, kind: Schema.String }) {
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Bind one family's reaction operations. Every operation is traced as
|
|
97
|
+
* `${type}.reactions.<operation>`, and the writes run in the caller's
|
|
98
|
+
* transaction.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* import { deepStrictEqual } from "node:assert";
|
|
103
|
+
* import * as Effect from "effect/Effect";
|
|
104
|
+
* import * as HashMap from "effect/HashMap";
|
|
105
|
+
* import * as Option from "effect/Option";
|
|
106
|
+
* import { defineReactions, reactionKinds } from "@thomasfosterau/pulpit/reactions";
|
|
107
|
+
* import { makeInMemoryReactionStore } from "@thomasfosterau/pulpit/testing";
|
|
108
|
+
*
|
|
109
|
+
* const reactions = defineReactions({
|
|
110
|
+
* type: "posts",
|
|
111
|
+
* kinds: reactionKinds(["like", "celebrate"]),
|
|
112
|
+
* // A target leaf where every node is readable; in an application, its `defineSubtype` operations.
|
|
113
|
+
* target: {
|
|
114
|
+
* get: (id) => Effect.succeed(id),
|
|
115
|
+
* getMany: (ids) => Effect.succeed(HashMap.fromIterable(ids.map((id) => [id, id] as const))),
|
|
116
|
+
* },
|
|
117
|
+
* reactions: makeInMemoryReactionStore(),
|
|
118
|
+
* viewer: Effect.succeedSome({ id: "u-1", role: "user" }),
|
|
119
|
+
* });
|
|
120
|
+
* const tally = Effect.runSync(
|
|
121
|
+
* Effect.andThen(reactions.react("post-1", "like"), reactions.tally(["post-1"])),
|
|
122
|
+
* );
|
|
123
|
+
* deepStrictEqual(Option.map(HashMap.get(tally, "post-1"), (t) => t.total), Option.some(1));
|
|
124
|
+
* ```
|
|
125
|
+
*
|
|
126
|
+
* @since 0.1.0
|
|
127
|
+
*/
|
|
128
|
+
export const defineReactions = (definition) => {
|
|
129
|
+
const { type, target, reactions } = definition;
|
|
130
|
+
const kinds = definition.kinds.kinds;
|
|
131
|
+
const signedIn = signedInAs(definition.viewer);
|
|
132
|
+
const react = Effect.fn(`${type}.reactions.react`)(function* (nodeId, kind) {
|
|
133
|
+
const account = yield* signedIn;
|
|
134
|
+
if (!kinds.includes(kind))
|
|
135
|
+
return yield* new InvalidReactionKind({ type, kind });
|
|
136
|
+
// A node the viewer can't read is missing to them (SEC-AUTH-ENUM-001).
|
|
137
|
+
yield* target.get(nodeId);
|
|
138
|
+
const replaced = yield* reactions.upsert({
|
|
139
|
+
nodeId,
|
|
140
|
+
accountId: account.id,
|
|
141
|
+
kind,
|
|
142
|
+
reactedAt: yield* nowDate,
|
|
143
|
+
});
|
|
144
|
+
return { replaced };
|
|
145
|
+
});
|
|
146
|
+
const unreact = Effect.fn(`${type}.reactions.unreact`)(function* (nodeId) {
|
|
147
|
+
const account = yield* signedIn;
|
|
148
|
+
yield* target.get(nodeId);
|
|
149
|
+
return { removed: yield* reactions.delete(nodeId, account.id) };
|
|
150
|
+
});
|
|
151
|
+
const zero = Record.fromEntries(Arr.map(kinds, (kind) => [kind, 0]));
|
|
152
|
+
const tally = Effect.fn(`${type}.reactions.tally`)(function* (nodeIds) {
|
|
153
|
+
const wanted = Arr.dedupe(nodeIds);
|
|
154
|
+
if (!Arr.isReadonlyArrayNonEmpty(wanted) || wanted.length > MAX_SET_VALUES) {
|
|
155
|
+
return HashMap.empty();
|
|
156
|
+
}
|
|
157
|
+
const readable = yield* target.getMany(wanted);
|
|
158
|
+
const shown = Arr.filter(wanted, (id) => HashMap.has(readable, id));
|
|
159
|
+
if (!Arr.isReadonlyArrayNonEmpty(shown))
|
|
160
|
+
return HashMap.empty();
|
|
161
|
+
const counted = yield* reactions.tally(shown);
|
|
162
|
+
return HashMap.fromIterable(Arr.map(shown, (id) => {
|
|
163
|
+
const stored = Option.getOrElse(HashMap.get(counted, id), () => ({}));
|
|
164
|
+
const counts = Record.map(zero, (_, kind) => Option.getOrElse(Record.get(stored, kind), () => 0));
|
|
165
|
+
const total = Arr.reduce(Record.values(counts), 0, (sum, count) => sum + count);
|
|
166
|
+
return [id, { counts, total }];
|
|
167
|
+
}));
|
|
168
|
+
});
|
|
169
|
+
const mine = Effect.fn(`${type}.reactions.mine`)(function* (request) {
|
|
170
|
+
const account = yield* signedIn;
|
|
171
|
+
const rows = yield* reactions.byAccount({
|
|
172
|
+
accountId: account.id,
|
|
173
|
+
limit: Math.max(1, Math.min(Math.trunc(request.limit), MAX_PAGE_LIMIT)),
|
|
174
|
+
offset: Math.max(0, Math.trunc(request.offset)),
|
|
175
|
+
});
|
|
176
|
+
if (!Arr.isReadonlyArrayNonEmpty(rows))
|
|
177
|
+
return rows;
|
|
178
|
+
const readable = yield* target.getMany(Arr.dedupe(Arr.map(rows, (row) => row.nodeId)));
|
|
179
|
+
return Arr.filter(rows, (row) => HashMap.has(readable, row.nodeId));
|
|
180
|
+
});
|
|
181
|
+
return { react, unreact, tally, mine };
|
|
182
|
+
};
|
|
183
|
+
//# sourceMappingURL=reactions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reactions.js","sourceRoot":"","sources":["../src/reactions.ts"],"names":[],"mappings":"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"}
|