-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDocumentIndexRepository.js
More file actions
245 lines (224 loc) · 8.48 KB
/
DocumentIndexRepository.js
File metadata and controls
245 lines (224 loc) · 8.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
/**
* DocumentIndexRepository.js — Ownership and visibility index for synced documents.
*
* Manages docIndex entries in a single nosqlClient collection named 'docIndex'.
* The compound storage key format is:
*
* docIndex:{encode(userId)}:{encode(app)}:{encode(docKey)}
*
* where encode() replaces ':' with '%3A', matching the namespaceKey.js convention.
*
* Key invariant: upsertOwnership() NEVER resets visibility, sharedWith, or
* shareToken. Once set, those fields are only mutated by their dedicated methods.
*
* nosqlClient can be:
* - Injected by CDI (autowired as 'nosqlClient')
* - Passed directly in tests
*
* logger is optional. If absent, logging is silently skipped.
*/
import { Filter } from '@alt-javascript/jsnosqlc-core';
const encode = (s) => String(s).replace(/:/g, '%3A');
export default class DocumentIndexRepository {
/**
* @param {import('@alt-javascript/jsnosqlc-core').Client} nosqlClient
* @param {object} [logger] — optional logger with .info() and .debug()
*/
constructor(nosqlClient, logger) {
this.nosqlClient = nosqlClient;
this.logger = logger ?? null;
}
_col() {
return this.nosqlClient.getCollection('docIndex');
}
/**
* Build the compound storage key for a docIndex entry.
* @param {string} userId
* @param {string} app
* @param {string} docKey
* @returns {string}
*/
_key(userId, app, docKey) {
return `docIndex:${encode(userId)}:${encode(app)}:${encode(docKey)}`;
}
/**
* Create a docIndex entry if absent; update only updatedAt if present.
* Never resets visibility, sharedWith, or shareToken.
*
* @param {string} userId
* @param {string} app
* @param {string} docKey
* @param {string} collection
* @returns {Promise<void>}
*/
async upsertOwnership(userId, app, docKey, collection) {
const key = this._key(userId, app, docKey);
const existing = await this._col().get(key);
const now = new Date().toISOString();
if (existing == null) {
const entry = {
_key: key,
docKey,
userId,
app,
collection,
visibility: 'private',
sharedWith: [],
shareToken: null,
createdAt: now,
updatedAt: now,
};
await this._col().store(key, entry);
this.logger?.info?.(`[DocumentIndexRepository] upsert create ${key}`);
} else {
const updated = { ...existing, updatedAt: now };
await this._col().store(key, updated);
this.logger?.info?.(`[DocumentIndexRepository] upsert touch ${key}`);
}
}
/**
* Retrieve a docIndex entry.
*
* @param {string} userId
* @param {string} app
* @param {string} docKey
* @returns {Promise<Object|null>}
*/
async get(userId, app, docKey) {
const key = this._key(userId, app, docKey);
const doc = await this._col().get(key);
this.logger?.debug?.(`[DocumentIndexRepository] get ${key} found=${doc != null}`);
return doc ?? null;
}
/**
* Set the visibility level of a docIndex entry.
*
* @param {string} userId
* @param {string} app
* @param {string} docKey
* @param {'private'|'shared'|'org'|'public'} visibility
* @returns {Promise<void>}
*/
async setVisibility(userId, app, docKey, visibility) {
const key = this._key(userId, app, docKey);
const existing = await this._col().get(key);
if (existing == null) throw new Error(`docIndex entry not found: ${key}`);
const updated = { ...existing, visibility, updatedAt: new Date().toISOString() };
await this._col().store(key, updated);
this.logger?.info?.(`[DocumentIndexRepository] setVisibility ${key} → ${visibility}`);
}
/**
* Add a (userId2, app2) share pair to sharedWith. Idempotent.
*
* @param {string} userId — owner
* @param {string} app — owner's app
* @param {string} docKey
* @param {string} userId2 — user to grant access to
* @param {string} app2 — app namespace the share is scoped to
* @returns {Promise<void>}
*/
async addSharedWith(userId, app, docKey, userId2, app2) {
const key = this._key(userId, app, docKey);
const existing = await this._col().get(key);
if (existing == null) throw new Error(`docIndex entry not found: ${key}`);
const alreadyPresent = (existing.sharedWith ?? []).some(
(e) => e.userId === userId2 && e.app === app2
);
if (alreadyPresent) {
this.logger?.debug?.(`[DocumentIndexRepository] addSharedWith ${key} already has ${userId2}:${app2}`);
return;
}
const sharedWith = [...(existing.sharedWith ?? []), { userId: userId2, app: app2 }];
const updated = { ...existing, sharedWith, updatedAt: new Date().toISOString() };
await this._col().store(key, updated);
this.logger?.info?.(`[DocumentIndexRepository] addSharedWith ${key} + ${userId2}:${app2}`);
}
/**
* Set or clear the share token on a docIndex entry.
*
* @param {string} userId
* @param {string} app
* @param {string} docKey
* @param {string|null} token — UUID token, or null to clear
* @returns {Promise<void>}
*/
async setShareToken(userId, app, docKey, token) {
const key = this._key(userId, app, docKey);
const existing = await this._col().get(key);
if (existing == null) throw new Error(`docIndex entry not found: ${key}`);
const updated = { ...existing, shareToken: token, updatedAt: new Date().toISOString() };
await this._col().store(key, updated);
this.logger?.info?.(`[DocumentIndexRepository] setShareToken ${key} token=${token}`);
}
/**
* Delete a docIndex entry by its composite key.
*
* @param {string} compositeKey — the `_key` field stored on the entry
* @returns {Promise<void>}
*/
async delete(compositeKey) {
await this._col().delete(compositeKey);
this.logger?.info?.(`[DocumentIndexRepository] delete ${compositeKey}`);
}
/**
* Return all docIndex entries for a given owner (userId + app).
* Performs a filter scan over the 'docIndex' collection.
*
* @param {string} userId
* @param {string} app
* @returns {Promise<Object[]>}
*/
async listByUser(userId, app) {
const filter = Filter.where('userId').eq(userId).and('app').eq(app).build();
const cursor = await this._col().find(filter);
const docs = await cursor.getDocuments();
this.logger?.info?.(`[DocumentIndexRepository] listByUser userId=${userId} app=${app} count=${docs.length}`);
return docs;
}
/**
* Return all docIndex entries visible to requestorId for a given app.
*
* ACL rules applied (in order):
* 1. Own docs — userId === requestorId (any visibility)
* 2. Public docs — visibility === 'public' (any owner)
* 3. Shared docs — visibility === 'shared' AND sharedWith contains
* { userId: requestorId, app }
* 4. Org docs — visibility === 'org' AND orgIds.length > 0
* // TODO S02: org cross-namespace fan-out requires owner orgId lookup
*
* Uses a single app-level scan (Filter.where('app').eq(app)) because the
* memory driver does NOT support dot-path array traversal in filter predicates.
* JS-side filtering is applied after the scan.
*
* @param {string} requestorId — userId of the requestor
* @param {string} app — app namespace to scan
* @param {string[]} [orgIds=[]] — org IDs the requestor belongs to (reserved for S02)
* @returns {Promise<Object[]>}
*/
async listAccessibleDocs(requestorId, app, orgIds = []) {
const filter = Filter.where('app').eq(app).build();
const cursor = await this._col().find(filter);
const docs = await cursor.getDocuments();
const visible = docs.filter((doc) => {
// Rule 1: own doc — always visible regardless of visibility setting
if (doc.userId === requestorId) return true;
// Rule 2: explicitly public
if (doc.visibility === 'public') return true;
// Rule 3: shared with requestorId in this app namespace
if (doc.visibility === 'shared') {
return (doc.sharedWith ?? []).some(
(e) => e.userId === requestorId && e.app === app
);
}
// Rule 4: org-visibility cross-namespace fan-out (deferred)
// TODO S02: org cross-namespace fan-out requires owner orgId lookup
// if (doc.visibility === 'org' && orgIds.length > 0) { ... }
return false;
});
this.logger?.info?.(
`[DocumentIndexRepository] listAccessibleDocs requestorId=${requestorId} app=${app} ` +
`scanned=${docs.length} visible=${visible.length}`
);
return visible;
}
}