@3sln/trove 0.0.2
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/README.md +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Page cursors for listing a collection.
|
|
2
|
+
//
|
|
3
|
+
// An OFFSET cursor is wrong for a live drive. `nextCursor = offset + limit` assumes the
|
|
4
|
+
// rows before the cut don't move, and in a shared collection they do: delete or rename
|
|
5
|
+
// an item that sorts early, or let someone else's upload land there, and the next page
|
|
6
|
+
// starts one row late — an item is skipped entirely, or served twice. With no folders,
|
|
7
|
+
// "load more" is the ONLY way to reach item 501, so a skipped file is invisible until a
|
|
8
|
+
// full refresh, and nothing tells the user it happened.
|
|
9
|
+
//
|
|
10
|
+
// A keyset cursor remembers WHERE the last page ended rather than how many rows it
|
|
11
|
+
// counted: "everything after (name, id)". Rows inserted before that point don't shift
|
|
12
|
+
// it, and rows removed from before it don't either. `id` is the tiebreaker, so a sort
|
|
13
|
+
// column with duplicates (two files the same size) still yields a total order.
|
|
14
|
+
|
|
15
|
+
/** Encode the last row of a page as the cursor for the next one. */
|
|
16
|
+
export function encodeCursor(sort, node) {
|
|
17
|
+
if (!node) return null;
|
|
18
|
+
return base64url(JSON.stringify(['k', sort, node[sort] ?? null, node.id]));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Decode a cursor into `{ value, id }`, or null.
|
|
23
|
+
*
|
|
24
|
+
* A cursor from a different sort order is discarded rather than misapplied: sorting by
|
|
25
|
+
* name and resuming from a size is a comparison against the wrong axis, which would
|
|
26
|
+
* silently drop most of the collection. Returning null restarts the listing, which is
|
|
27
|
+
* the honest answer to "your bookmark no longer means anything".
|
|
28
|
+
*/
|
|
29
|
+
export function decodeCursor(sort, cursor) {
|
|
30
|
+
if (!cursor) return null;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(unbase64url(String(cursor)));
|
|
33
|
+
if (!Array.isArray(parsed) || parsed[0] !== 'k' || parsed[1] !== sort) return null;
|
|
34
|
+
return { value: parsed[2], id: parsed[3] };
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Is `node` strictly after the cursor position, under this sort direction? */
|
|
41
|
+
export function afterCursor(node, sort, at, desc) {
|
|
42
|
+
const a = node[sort];
|
|
43
|
+
const b = at.value;
|
|
44
|
+
const cmp = compareValues(a, b);
|
|
45
|
+
if (cmp !== 0) return desc ? cmp < 0 : cmp > 0;
|
|
46
|
+
return desc ? node.id < at.id : node.id > at.id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function compareValues(a, b) {
|
|
50
|
+
// Text compares case-insensitively to match the stores' NOCASE ordering; anything
|
|
51
|
+
// else compares naturally.
|
|
52
|
+
if (typeof a === 'string' && typeof b === 'string') {
|
|
53
|
+
const la = a.toLowerCase();
|
|
54
|
+
const lb = b.toLowerCase();
|
|
55
|
+
return la < lb ? -1 : la > lb ? 1 : 0;
|
|
56
|
+
}
|
|
57
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// base64url without padding, over UTF-8, working in both Node and the browser.
|
|
61
|
+
function base64url(str) {
|
|
62
|
+
const bytes = new TextEncoder().encode(str);
|
|
63
|
+
let bin = '';
|
|
64
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
65
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
66
|
+
}
|
|
67
|
+
function unbase64url(str) {
|
|
68
|
+
const b64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
69
|
+
const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
|
|
70
|
+
const bytes = new Uint8Array(bin.length);
|
|
71
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
72
|
+
return new TextDecoder().decode(bytes);
|
|
73
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// MetadataStore — the source of truth for every item in the drive. It maps items to
|
|
2
|
+
// storage keys (so the blob store never sees a user-visible name), and holds each
|
|
3
|
+
// item's *contributions*: what indexers (and the user) attach to it, namespaced by
|
|
4
|
+
// contributor id so they never clobber one another and can be removed independently.
|
|
5
|
+
//
|
|
6
|
+
// THERE IS NO HIERARCHY. A collection is a flat set of uniquely-named items. Structure
|
|
7
|
+
// comes from items linking to each other (`trove:` URIs — see ../links.js) and from
|
|
8
|
+
// search, not from containers: a markdown document that links its sources does what a
|
|
9
|
+
// folder did, except it can say why those things belong together, an item can appear in
|
|
10
|
+
// as many of them as it likes, and the grouping is searchable content rather than an
|
|
11
|
+
// invisible box. So there is no parentId, no path, and no folder node.
|
|
12
|
+
//
|
|
13
|
+
// A contributor (an indexer, or the reserved 'user' scope) writes up to three
|
|
14
|
+
// things per node: semantic search text (which lives in SearchService, not here),
|
|
15
|
+
// `tags` (filterable key/values), and `metadata` (arbitrary — e.g. an audiobook's
|
|
16
|
+
// chapter index). This store holds the tags + metadata:
|
|
17
|
+
//
|
|
18
|
+
// {
|
|
19
|
+
// id, collectionId, name,
|
|
20
|
+
// size, contentType, storageKey|null, etag, createdAt, updatedAt,
|
|
21
|
+
// meta: {}, // user-facing scalar metadata (description…)
|
|
22
|
+
// contributions: { [contributorId]: { tags?: {}, metadata?: {} } },
|
|
23
|
+
// tags: {} // all contributors' tags merged (this is what filtering queries)
|
|
24
|
+
// }
|
|
25
|
+
//
|
|
26
|
+
// Internally the two are stored together in one JSON column with a reserved
|
|
27
|
+
// '#tags' key holding the merged view (denormalized so filtering stays a simple
|
|
28
|
+
// json_extract). Implement this interface over anything (Memory/Sqlite/D1/…).
|
|
29
|
+
|
|
30
|
+
import { TroveError } from '../errors.js';
|
|
31
|
+
|
|
32
|
+
// The reserved key under which the merged, queryable tag view is denormalized
|
|
33
|
+
// alongside the per-contributor namespaces.
|
|
34
|
+
export const MERGED_TAGS = '#tags';
|
|
35
|
+
|
|
36
|
+
// The links indexer's contributor id, and the key it stores outbound `trove:` links
|
|
37
|
+
// under. Backlink queries read exactly this, so it lives beside the tag view rather
|
|
38
|
+
// than being a string repeated across the stores.
|
|
39
|
+
export const LINKS_CONTRIBUTOR = 'core.links';
|
|
40
|
+
export const LINKS_KEY = 'links';
|
|
41
|
+
|
|
42
|
+
/** The outbound trove: links recorded on a node, or []. */
|
|
43
|
+
export function outboundLinks(node) {
|
|
44
|
+
const c = node?.contributions?.[LINKS_CONTRIBUTOR]?.metadata?.[LINKS_KEY];
|
|
45
|
+
return Array.isArray(c) ? c : [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Merge every contributor's `tags` into one flat map (dropping null/removed). */
|
|
49
|
+
export function mergeContributionTags(raw) {
|
|
50
|
+
const merged = {};
|
|
51
|
+
for (const [key, c] of Object.entries(raw || {})) {
|
|
52
|
+
if (key === MERGED_TAGS) continue;
|
|
53
|
+
if (c && c.tags) for (const [k, v] of Object.entries(c.tags)) merged[k] = v;
|
|
54
|
+
}
|
|
55
|
+
for (const k of Object.keys(merged)) if (merged[k] == null) delete merged[k];
|
|
56
|
+
return merged;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Split the raw stored JSON into the node's exposed `contributions` + `tags`. */
|
|
60
|
+
export function splitContributions(raw) {
|
|
61
|
+
const contributions = {};
|
|
62
|
+
for (const [k, v] of Object.entries(raw || {})) if (k !== MERGED_TAGS) contributions[k] = v;
|
|
63
|
+
return { contributions, tags: (raw && raw[MERGED_TAGS]) || {} };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reconstruct the raw contributions JSON from a node object (accepts the exposed
|
|
67
|
+
* `contributions`+`tags` shape or a legacy raw `facets` field). */
|
|
68
|
+
export function rawFacetsFromNode(node) {
|
|
69
|
+
if (node.facets) return node.facets;
|
|
70
|
+
const raw = { ...(node.contributions || {}) };
|
|
71
|
+
if (node.tags && Object.keys(node.tags).length) raw[MERGED_TAGS] = node.tags;
|
|
72
|
+
return raw;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Apply a `{ tags, metadata }` contribution onto the raw map and re-merge tags. */
|
|
76
|
+
export function applyContribution(raw, contributorId, { tags, metadata } = {}) {
|
|
77
|
+
const next = { ...(raw || {}) };
|
|
78
|
+
const cur = next[contributorId] || {};
|
|
79
|
+
const merged = { ...cur };
|
|
80
|
+
if (tags) merged.tags = { ...(cur.tags || {}), ...tags };
|
|
81
|
+
if (metadata) merged.metadata = { ...(cur.metadata || {}), ...metadata };
|
|
82
|
+
next[contributorId] = merged;
|
|
83
|
+
next[MERGED_TAGS] = mergeContributionTags(next);
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class MetadataStore {
|
|
88
|
+
/** Prepare storage (schema/migrations); call once at startup. */
|
|
89
|
+
async init() {}
|
|
90
|
+
|
|
91
|
+
/** @returns {Promise<object|null>} */
|
|
92
|
+
async getById(id) {
|
|
93
|
+
throw TroveError.unsupported('getById not implemented');
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve an item by its name within a collection. Names are unique per collection,
|
|
97
|
+
* which is what lets a `trove:<collection>?name=…` link address exactly one thing.
|
|
98
|
+
* @returns {Promise<object|null>}
|
|
99
|
+
*/
|
|
100
|
+
async getByName(collectionId, name) {
|
|
101
|
+
throw TroveError.unsupported('getByName not implemented');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The items in a collection.
|
|
105
|
+
* @param {string} collectionId
|
|
106
|
+
* @param {{sort?: 'name'|'size'|'updatedAt', order?: 'asc'|'desc', limit?: number, cursor?: string}} [opts]
|
|
107
|
+
* @returns {Promise<{items: object[], nextCursor: string|null}>}
|
|
108
|
+
*/
|
|
109
|
+
async listItems(collectionId, opts) {
|
|
110
|
+
throw TroveError.unsupported('listItems not implemented');
|
|
111
|
+
}
|
|
112
|
+
/** Insert an item. Rejects ALREADY_EXISTS on a (collectionId,name) collision. */
|
|
113
|
+
async create(node) {
|
|
114
|
+
throw TroveError.unsupported('create not implemented');
|
|
115
|
+
}
|
|
116
|
+
/** Shallow-merge a patch into an item (name changes go through rename). */
|
|
117
|
+
async update(id, patch) {
|
|
118
|
+
throw TroveError.unsupported('update not implemented');
|
|
119
|
+
}
|
|
120
|
+
/** PERMANENTLY delete a single item. The trash lives above this, in the Vfs. */
|
|
121
|
+
async remove(id) {
|
|
122
|
+
throw TroveError.unsupported('remove not implemented');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Move an item to the trash: still stored, no longer part of the drive.
|
|
126
|
+
*
|
|
127
|
+
* Optional — a store that doesn't implement it makes deletes permanent, which is a
|
|
128
|
+
* legitimate choice for an ephemeral or append-only backend. Where it IS implemented,
|
|
129
|
+
* every read path must exclude trashed rows, and name uniqueness must apply only to
|
|
130
|
+
* live ones; otherwise deleting `notes.md` would prevent ever creating another.
|
|
131
|
+
*/
|
|
132
|
+
async softDelete(id, at) {
|
|
133
|
+
throw TroveError.unsupported('softDelete not implemented');
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Bring a trashed item back, optionally under a different name. The rename is part of
|
|
137
|
+
* the restore rather than a separate step, so a name freed for a replacement can be
|
|
138
|
+
* resolved without a window where the item is live under a colliding name.
|
|
139
|
+
* Rejects ALREADY_EXISTS if the name is taken.
|
|
140
|
+
*/
|
|
141
|
+
async restore(id, newName) {
|
|
142
|
+
throw TroveError.unsupported('restore not implemented');
|
|
143
|
+
}
|
|
144
|
+
/** Trashed items, newest first. @returns {Promise<object[]>} */
|
|
145
|
+
async listTrash(collectionId, opts) {
|
|
146
|
+
throw TroveError.unsupported('listTrash not implemented');
|
|
147
|
+
}
|
|
148
|
+
/** Trashed before `cutoff` — what the retention sweep collects. */
|
|
149
|
+
async trashedBefore(cutoff, limit) {
|
|
150
|
+
throw TroveError.unsupported('trashedBefore not implemented');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The storage keys of TRASHED items in a collection.
|
|
154
|
+
*
|
|
155
|
+
* The scanner needs these and cannot get them from `listItems`, which is live-only by
|
|
156
|
+
* design. Without them a trashed file's object looks like one that arrived from
|
|
157
|
+
* outside, so a scan adopts it a second time — resurrecting the deleted file under a
|
|
158
|
+
* new id that shares the original's storage key, at which point emptying the trash
|
|
159
|
+
* deletes the live copy's bytes.
|
|
160
|
+
*
|
|
161
|
+
* Keys rather than rows because that is all the caller needs, and a collection's whole
|
|
162
|
+
* trash as full records is a lot of memory for a set membership test.
|
|
163
|
+
* @returns {Promise<Set<string>>}
|
|
164
|
+
*/
|
|
165
|
+
async trashedStorageKeys(collectionId) {
|
|
166
|
+
throw TroveError.unsupported('trashedStorageKeys not implemented');
|
|
167
|
+
}
|
|
168
|
+
/** Rename an item. Rejects ALREADY_EXISTS if the name is taken in its collection. */
|
|
169
|
+
async rename(id, newName) {
|
|
170
|
+
throw TroveError.unsupported('rename not implemented');
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Merge a contributor's `{ tags, metadata }` onto a node (namespaced by
|
|
174
|
+
* `contributorId`), and update the merged tag view. Either field is optional.
|
|
175
|
+
*/
|
|
176
|
+
async setContribution(id, contributorId, contribution) {
|
|
177
|
+
throw TroveError.unsupported('setContribution not implemented');
|
|
178
|
+
}
|
|
179
|
+
/** Remove a contributor's entire namespace (tags + metadata) from a node. */
|
|
180
|
+
async clearContribution(id, contributorId) {
|
|
181
|
+
throw TroveError.unsupported('clearContribution not implemented');
|
|
182
|
+
}
|
|
183
|
+
/** Simple substring/name search fallback (SearchService may do better). */
|
|
184
|
+
async searchByName(query, opts) {
|
|
185
|
+
throw TroveError.unsupported('searchByName not implemented');
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Page through EVERY item across all collections in stable id order, for drive-wide
|
|
189
|
+
* sweeps (indexer backfill/purge). Distinct from listItems, which is one collection's
|
|
190
|
+
* contents for a reader: this one ignores collections and ordering preferences, and
|
|
191
|
+
* exists so a sweep can resume from `afterId` without holding the drive in memory.
|
|
192
|
+
* @returns {Promise<object[]>}
|
|
193
|
+
*/
|
|
194
|
+
async scanItems({ afterId = null, limit = 200 } = {}) {
|
|
195
|
+
throw TroveError.unsupported('scanItems not implemented');
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* How many items the drive holds, across all collections. Optional: it exists so a
|
|
199
|
+
* drive-wide sweep can show real progress ("142 of 3,100") instead of a spinner. A
|
|
200
|
+
* store that can't answer cheaply should return null rather than guess — the caller
|
|
201
|
+
* falls back to an indeterminate indicator, which is honest.
|
|
202
|
+
* @returns {Promise<number|null>}
|
|
203
|
+
*/
|
|
204
|
+
async countItems(collectionId) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* How many items a collection holds and how many bytes they occupy.
|
|
209
|
+
*
|
|
210
|
+
* Separate from `listItems` because a page is not a drive: a client that reports the
|
|
211
|
+
* length of the page it received tells the user their 3,000-file drive holds 500
|
|
212
|
+
* files, which is not a rounding error but a wrong number. Optional; a store that
|
|
213
|
+
* can't answer returns null and the UI says nothing rather than guessing.
|
|
214
|
+
* @returns {Promise<{items: number, bytes: number}|null>}
|
|
215
|
+
*/
|
|
216
|
+
async collectionStats(collectionId) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Drive-wide tag/property query. `filters` is a list of
|
|
221
|
+
* `{ key, present } | { key, op, value }` (op ∈ = != < <= > >=), matched against
|
|
222
|
+
* a node's merged `tags` (+ meta). `opts`: { q (name substring), collectionIds,
|
|
223
|
+
* limit }. Returns matching nodes newest-first.
|
|
224
|
+
*/
|
|
225
|
+
async findByTags(filters, opts) {
|
|
226
|
+
throw TroveError.unsupported('findByTags not implemented');
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Items that link TO any of `uris` — the backlinks of an item, and the reason a
|
|
230
|
+
* flat drive stays navigable: without them you can follow a document's links out
|
|
231
|
+
* but never discover what gathers a given item up.
|
|
232
|
+
*
|
|
233
|
+
* `uris` are canonical `trove:` URIs (an item is addressable by name AND by id, so
|
|
234
|
+
* the caller passes both forms). Matched against the links contribution written by
|
|
235
|
+
* the links indexer.
|
|
236
|
+
* @param {string[]} uris
|
|
237
|
+
* @param {{limit?: number, collectionIds?: string[]}} [opts]
|
|
238
|
+
*/
|
|
239
|
+
async findLinksTo(uris, opts) {
|
|
240
|
+
throw TroveError.unsupported('findLinksTo not implemented');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async close() {}
|
|
244
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// In-memory MetadataStore. Reference implementation + test double. Nodes live in a
|
|
2
|
+
// Map keyed by id, with one secondary index on (collectionId, name) — which is the
|
|
3
|
+
// whole shape of the namespace now that there is no hierarchy: a collection is a flat
|
|
4
|
+
// set of uniquely-named items, and structure comes from `trove:` links between them.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
MetadataStore, MERGED_TAGS, LINKS_CONTRIBUTOR, LINKS_KEY,
|
|
8
|
+
mergeContributionTags, splitContributions, applyContribution, rawFacetsFromNode,
|
|
9
|
+
} from './interface.js';
|
|
10
|
+
import { TroveError } from '../errors.js';
|
|
11
|
+
import { newId } from '../util.js';
|
|
12
|
+
import { decodeCursor, encodeCursor, afterCursor, compareValues } from './cursor.js';
|
|
13
|
+
import { matchTagFilters } from '../search/tagMatch.js';
|
|
14
|
+
|
|
15
|
+
export class MemoryStore extends MetadataStore {
|
|
16
|
+
constructor() {
|
|
17
|
+
super();
|
|
18
|
+
this.nodes = new Map(); // id -> node
|
|
19
|
+
this.byName = new Map(); // `${collectionId}\0${name}` -> id
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async init() {}
|
|
23
|
+
|
|
24
|
+
#key(collectionId, name) {
|
|
25
|
+
return `${collectionId}\0${name}`;
|
|
26
|
+
}
|
|
27
|
+
#index(node) {
|
|
28
|
+
this.nodes.set(node.id, node);
|
|
29
|
+
this.byName.set(this.#key(node.collectionId, node.name), node.id);
|
|
30
|
+
}
|
|
31
|
+
#deindex(node) {
|
|
32
|
+
this.nodes.delete(node.id);
|
|
33
|
+
const key = this.#key(node.collectionId, node.name);
|
|
34
|
+
// Only when the name still resolves to THIS node. `softDelete` deliberately frees
|
|
35
|
+
// the name so a replacement can take it, so by the time the trash is emptied that
|
|
36
|
+
// key belongs to a different, LIVE file. Deleting it blindly made that live file
|
|
37
|
+
// unreachable by name — `trove:default?name=notes.md` stopped resolving, and the
|
|
38
|
+
// uniqueness check stopped seeing it, so the next upload created a second row
|
|
39
|
+
// under the same name.
|
|
40
|
+
if (this.byName.get(key) === node.id) this.byName.delete(key);
|
|
41
|
+
}
|
|
42
|
+
/** Live rows only. A trashed item keeps its row but must not answer as itself. */
|
|
43
|
+
#live() {
|
|
44
|
+
return [...this.nodes.values()].filter((n) => !n.deletedAt);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getById(id) {
|
|
48
|
+
return clone(this.nodes.get(id));
|
|
49
|
+
}
|
|
50
|
+
async getByName(collectionId = 'default', name) {
|
|
51
|
+
const id = this.byName.get(this.#key(collectionId, name));
|
|
52
|
+
const node = id ? this.nodes.get(id) : null;
|
|
53
|
+
return node && !node.deletedAt ? clone(node) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async listItems(collectionId = 'default', opts = {}) {
|
|
57
|
+
// Same resolution as the sqlite store — an unknown sort falls back to `name` in
|
|
58
|
+
// both, and the cursor is keyed by the RESOLVED field so the two never disagree.
|
|
59
|
+
const sort = ['name', 'size', 'updatedAt'].includes(opts.sort) ? opts.sort : 'name';
|
|
60
|
+
const desc = opts.order === 'desc';
|
|
61
|
+
const dir = desc ? -1 : 1;
|
|
62
|
+
const items = this.#live().filter((n) => n.collectionId === collectionId);
|
|
63
|
+
// `id` breaks ties, so two files of the same size still have one stable order — the
|
|
64
|
+
// cursor below depends on the ordering being total.
|
|
65
|
+
// The SAME comparison the cursor uses. This sorted with raw `<`/`>` — code-unit and
|
|
66
|
+
// case-sensitive — while `afterCursor` lowercases both sides, so on mixed-case names
|
|
67
|
+
// the two orders disagreed and items fell straight through the page boundary, never
|
|
68
|
+
// returned by any page. Worse through the scanner, which pages `listItems` to learn
|
|
69
|
+
// what it already has: the files it never saw were adopted again on every scan, one
|
|
70
|
+
// duplicate per run, all sharing one storage key.
|
|
71
|
+
items.sort((a, b) => compareForSort(a, b, sort, dir));
|
|
72
|
+
const limit = opts.limit ?? 500;
|
|
73
|
+
const at = decodeCursor(sort, opts.cursor);
|
|
74
|
+
const rest = at ? items.filter((n) => afterCursor(n, sort, at, desc)) : items;
|
|
75
|
+
const page = rest.slice(0, limit);
|
|
76
|
+
return {
|
|
77
|
+
items: page.map(clone),
|
|
78
|
+
nextCursor: rest.length > limit ? encodeCursor(sort, page[page.length - 1]) : null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async create(node) {
|
|
83
|
+
const collectionId = node.collectionId || 'default';
|
|
84
|
+
if (!node.name) throw TroveError.invalid('An item needs a name');
|
|
85
|
+
if (this.byName.has(this.#key(collectionId, node.name))) throw TroveError.alreadyExists(node.name);
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const full = {
|
|
88
|
+
id: node.id || newId('itm'),
|
|
89
|
+
collectionId, name: node.name,
|
|
90
|
+
size: node.size ?? 0, contentType: node.contentType ?? null,
|
|
91
|
+
storageKey: node.storageKey ?? null, etag: node.etag ?? null,
|
|
92
|
+
createdAt: now, updatedAt: now, meta: node.meta ?? {}, facets: rawFacetsFromNode(node),
|
|
93
|
+
};
|
|
94
|
+
this.#index(full);
|
|
95
|
+
return clone(full);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async update(id, patch) {
|
|
99
|
+
const node = this.nodes.get(id);
|
|
100
|
+
if (!node) throw TroveError.notFound('Item');
|
|
101
|
+
for (const k of ['size', 'contentType', 'storageKey', 'etag', 'meta']) {
|
|
102
|
+
if (k in patch) node[k] = patch[k];
|
|
103
|
+
}
|
|
104
|
+
node.updatedAt = Date.now();
|
|
105
|
+
return clone(node);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Permanently forget an item. The trash is a VFS concern; this is the real delete. */
|
|
109
|
+
async remove(id) {
|
|
110
|
+
const node = this.nodes.get(id);
|
|
111
|
+
if (!node) return;
|
|
112
|
+
this.#deindex(node);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async softDelete(id, at = Date.now()) {
|
|
116
|
+
const node = this.nodes.get(id);
|
|
117
|
+
if (!node) throw TroveError.notFound('Item');
|
|
118
|
+
node.deletedAt = at;
|
|
119
|
+
node.updatedAt = at;
|
|
120
|
+
// Free the name so a replacement can take it — otherwise the trash holds the name
|
|
121
|
+
// hostage and you can never re-create what you just deleted.
|
|
122
|
+
this.byName.delete(this.#key(node.collectionId, node.name));
|
|
123
|
+
return clone(node);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async restore(id, newName = null) {
|
|
127
|
+
const node = this.nodes.get(id);
|
|
128
|
+
if (!node) throw TroveError.notFound('Item');
|
|
129
|
+
const name = newName || node.name;
|
|
130
|
+
if (this.byName.has(this.#key(node.collectionId, name))) throw TroveError.alreadyExists(name);
|
|
131
|
+
delete node.deletedAt;
|
|
132
|
+
node.name = name;
|
|
133
|
+
node.updatedAt = Date.now();
|
|
134
|
+
this.byName.set(this.#key(node.collectionId, name), node.id);
|
|
135
|
+
return clone(node);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async listTrash(collectionId, { limit = 200, before = null } = {}) {
|
|
139
|
+
return [...this.nodes.values()]
|
|
140
|
+
.filter((n) => n.deletedAt && (!collectionId || n.collectionId === collectionId) && (!before || n.deletedAt < before))
|
|
141
|
+
.sort((a, b) => b.deletedAt - a.deletedAt)
|
|
142
|
+
.slice(0, limit)
|
|
143
|
+
.map(clone);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async trashedStorageKeys(collectionId) {
|
|
147
|
+
const keys = new Set();
|
|
148
|
+
for (const n of this.nodes.values()) {
|
|
149
|
+
if (n.deletedAt && n.storageKey && (!collectionId || n.collectionId === collectionId)) keys.add(n.storageKey);
|
|
150
|
+
}
|
|
151
|
+
return keys;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async trashedBefore(cutoff, limit = 500) {
|
|
155
|
+
return [...this.nodes.values()]
|
|
156
|
+
.filter((n) => n.deletedAt && n.deletedAt < cutoff)
|
|
157
|
+
.sort((a, b) => a.deletedAt - b.deletedAt)
|
|
158
|
+
.slice(0, limit)
|
|
159
|
+
.map(clone);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async rename(id, newName) {
|
|
163
|
+
const node = this.nodes.get(id);
|
|
164
|
+
if (!node || node.deletedAt) throw TroveError.notFound('Item');
|
|
165
|
+
if (!newName) throw TroveError.invalid('An item needs a name');
|
|
166
|
+
if (newName === node.name) return clone(node);
|
|
167
|
+
if (this.byName.has(this.#key(node.collectionId, newName))) throw TroveError.alreadyExists(newName);
|
|
168
|
+
this.#deindex(node);
|
|
169
|
+
node.name = newName;
|
|
170
|
+
node.updatedAt = Date.now();
|
|
171
|
+
this.#index(node);
|
|
172
|
+
return clone(node);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async setContribution(id, contributorId, contribution) {
|
|
176
|
+
const node = this.nodes.get(id);
|
|
177
|
+
if (!node) throw TroveError.notFound('Item');
|
|
178
|
+
node.facets = applyContribution(node.facets, contributorId, contribution);
|
|
179
|
+
node.updatedAt = Date.now();
|
|
180
|
+
return clone(node);
|
|
181
|
+
}
|
|
182
|
+
async clearContribution(id, contributorId) {
|
|
183
|
+
const node = this.nodes.get(id);
|
|
184
|
+
if (!node) return;
|
|
185
|
+
const { [contributorId]: _drop, ...rest } = node.facets;
|
|
186
|
+
node.facets = { ...rest, [MERGED_TAGS]: mergeContributionTags(rest) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async searchByName(query, opts = {}) {
|
|
190
|
+
const q = query.toLowerCase();
|
|
191
|
+
return this.#live()
|
|
192
|
+
.filter((n) => n.name.toLowerCase().includes(q))
|
|
193
|
+
.filter((n) => !opts.collectionId || n.collectionId === opts.collectionId)
|
|
194
|
+
.slice(0, opts.limit ?? 50)
|
|
195
|
+
.map(clone);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async scanItems({ afterId = null, limit = 200 } = {}) {
|
|
199
|
+
const files = this.#live().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
200
|
+
const start = afterId ? files.findIndex((n) => n.id > afterId) : 0;
|
|
201
|
+
const from = start === -1 ? files.length : start;
|
|
202
|
+
return files.slice(from, from + limit).map(clone);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async countItems(collectionId) {
|
|
206
|
+
const live = this.#live();
|
|
207
|
+
return collectionId ? live.filter((n) => n.collectionId === collectionId).length : live.length;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async collectionStats(collectionId = 'default') {
|
|
211
|
+
let items = 0;
|
|
212
|
+
let bytes = 0;
|
|
213
|
+
let trashed = 0;
|
|
214
|
+
for (const node of this.nodes.values()) {
|
|
215
|
+
if (node.collectionId !== collectionId) continue;
|
|
216
|
+
if (node.deletedAt) { trashed++; continue; }
|
|
217
|
+
items++;
|
|
218
|
+
bytes += node.size || 0;
|
|
219
|
+
}
|
|
220
|
+
return { items, bytes, trashed };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async findByTags(filters = [], opts = {}) {
|
|
224
|
+
const q = opts.q ? opts.q.toLowerCase() : null;
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const node of this.#live()) {
|
|
227
|
+
if (opts.collectionIds && !opts.collectionIds.includes(node.collectionId)) continue;
|
|
228
|
+
if (q && !node.name.toLowerCase().includes(q)) continue;
|
|
229
|
+
if (matchTags(node, filters)) out.push(node);
|
|
230
|
+
}
|
|
231
|
+
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
232
|
+
return out.slice(0, opts.limit ?? 100).map(clone);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async findLinksTo(uris = [], opts = {}) {
|
|
236
|
+
if (!uris.length) return [];
|
|
237
|
+
const want = new Set(uris);
|
|
238
|
+
const out = [];
|
|
239
|
+
for (const node of this.#live()) {
|
|
240
|
+
if (opts.collectionIds && !opts.collectionIds.includes(node.collectionId)) continue;
|
|
241
|
+
const links = node.facets?.[LINKS_CONTRIBUTOR]?.metadata?.[LINKS_KEY];
|
|
242
|
+
if (Array.isArray(links) && links.some((l) => want.has(l))) out.push(node);
|
|
243
|
+
}
|
|
244
|
+
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
245
|
+
return out.slice(0, opts.limit ?? 100).map(clone);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Split the internal raw facets into the exposed contributions + tags on read.
|
|
250
|
+
function clone(n) {
|
|
251
|
+
if (!n) return n;
|
|
252
|
+
const copy = JSON.parse(JSON.stringify(n));
|
|
253
|
+
const { contributions, tags } = splitContributions(copy.facets || {});
|
|
254
|
+
copy.contributions = contributions;
|
|
255
|
+
copy.tags = tags;
|
|
256
|
+
delete copy.facets;
|
|
257
|
+
return copy;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// One matcher, shared with the transformer, the browser and (in SQL) the sqlite store.
|
|
261
|
+
function matchTags(node, filters) {
|
|
262
|
+
return matchTagFilters(node, filters, mergeContributionTags(node.facets || {}));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// One ordering, shared with the cursor. `id` breaks ties so the order is total.
|
|
266
|
+
function compareForSort(a, b, sort, dir) {
|
|
267
|
+
const c = compareValues(a[sort], b[sort]);
|
|
268
|
+
if (c !== 0) return c * dir;
|
|
269
|
+
return a.id < b.id ? -1 * dir : a.id > b.id ? dir : 0;
|
|
270
|
+
}
|