@noy-db/to-aws-s3 0.2.0-pre.1 → 0.2.0-pre.11
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/dist/index.cjs +100 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +33 -2
- package/dist/index.d.ts +33 -2
- package/dist/index.js +104 -11
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -20,16 +20,102 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
-
s3: () => s3
|
|
23
|
+
s3: () => s3,
|
|
24
|
+
s3Bundle: () => s3Bundle
|
|
24
25
|
});
|
|
25
26
|
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
var import_hub2 = require("@noy-db/hub");
|
|
28
|
+
var import_client_s32 = require("@aws-sdk/client-s3");
|
|
29
|
+
|
|
30
|
+
// src/bundle.ts
|
|
26
31
|
var import_hub = require("@noy-db/hub");
|
|
27
32
|
var import_client_s3 = require("@aws-sdk/client-s3");
|
|
28
|
-
|
|
33
|
+
var SUFFIX = ".noydb";
|
|
34
|
+
function stripQuotes(etag) {
|
|
35
|
+
return (etag ?? "").replace(/^"|"$/g, "");
|
|
36
|
+
}
|
|
37
|
+
function s3Bundle(options) {
|
|
29
38
|
const { bucket, prefix = "" } = options;
|
|
30
39
|
const client = options.client ?? new import_client_s3.S3Client({
|
|
31
40
|
...options.region ? { region: options.region } : {}
|
|
32
41
|
});
|
|
42
|
+
const listPrefix = prefix ? `${prefix}/` : "";
|
|
43
|
+
function objectKey(vaultId) {
|
|
44
|
+
return `${listPrefix}${vaultId}${SUFFIX}`;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
kind: "bundle",
|
|
48
|
+
name: "s3",
|
|
49
|
+
async readBundle(vaultId) {
|
|
50
|
+
try {
|
|
51
|
+
const res = await client.send(new import_client_s3.GetObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }));
|
|
52
|
+
if (!res.Body) return null;
|
|
53
|
+
const bytes = await res.Body.transformToByteArray();
|
|
54
|
+
return { bytes, version: stripQuotes(res.ETag) };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (err instanceof Error && (err.name === "NoSuchKey" || err.name === "NotFound")) return null;
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
async writeBundle(vaultId, bytes, expectedVersion) {
|
|
61
|
+
try {
|
|
62
|
+
const res = await client.send(new import_client_s3.PutObjectCommand({
|
|
63
|
+
Bucket: bucket,
|
|
64
|
+
Key: objectKey(vaultId),
|
|
65
|
+
Body: bytes,
|
|
66
|
+
ContentType: "application/octet-stream",
|
|
67
|
+
...expectedVersion !== null ? { IfMatch: expectedVersion } : {}
|
|
68
|
+
}));
|
|
69
|
+
let version = stripQuotes(res.ETag);
|
|
70
|
+
if (!version) {
|
|
71
|
+
const head = await client.send(new import_client_s3.HeadObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }));
|
|
72
|
+
version = stripQuotes(head.ETag);
|
|
73
|
+
}
|
|
74
|
+
return { version };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const status = err.$metadata?.httpStatusCode;
|
|
77
|
+
if (err instanceof Error && (err.name === "PreconditionFailed" || status === 412)) {
|
|
78
|
+
throw new import_hub.BundleVersionConflictError(
|
|
79
|
+
`S3 bundle "${vaultId}" changed since expectedVersion="${expectedVersion}".`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
async deleteBundle(vaultId) {
|
|
86
|
+
await client.send(new import_client_s3.DeleteObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }));
|
|
87
|
+
},
|
|
88
|
+
async listBundles() {
|
|
89
|
+
const out = [];
|
|
90
|
+
let token;
|
|
91
|
+
do {
|
|
92
|
+
const res = await client.send(new import_client_s3.ListObjectsV2Command({
|
|
93
|
+
Bucket: bucket,
|
|
94
|
+
Prefix: listPrefix,
|
|
95
|
+
...token ? { ContinuationToken: token } : {}
|
|
96
|
+
}));
|
|
97
|
+
for (const obj of res.Contents ?? []) {
|
|
98
|
+
const key = obj.Key ?? "";
|
|
99
|
+
if (!key.endsWith(SUFFIX)) continue;
|
|
100
|
+
out.push({
|
|
101
|
+
vaultId: key.slice(listPrefix.length, -SUFFIX.length),
|
|
102
|
+
version: stripQuotes(obj.ETag),
|
|
103
|
+
size: obj.Size ?? 0
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
token = res.IsTruncated ? res.NextContinuationToken : void 0;
|
|
107
|
+
} while (token);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/index.ts
|
|
114
|
+
function s3(options) {
|
|
115
|
+
const { bucket, prefix = "" } = options;
|
|
116
|
+
const client = options.client ?? new import_client_s32.S3Client({
|
|
117
|
+
...options.region ? { region: options.region } : {}
|
|
118
|
+
});
|
|
33
119
|
function objectKey(vault, collection, id) {
|
|
34
120
|
const parts = [vault, collection, `${id}.json`];
|
|
35
121
|
return prefix ? `${prefix}/${parts.join("/")}` : parts.join("/");
|
|
@@ -45,7 +131,7 @@ function s3(options) {
|
|
|
45
131
|
name: "s3",
|
|
46
132
|
async get(vault, collection, id) {
|
|
47
133
|
try {
|
|
48
|
-
const result = await client.send(new
|
|
134
|
+
const result = await client.send(new import_client_s32.GetObjectCommand({
|
|
49
135
|
Bucket: bucket,
|
|
50
136
|
Key: objectKey(vault, collection, id)
|
|
51
137
|
}));
|
|
@@ -63,10 +149,10 @@ function s3(options) {
|
|
|
63
149
|
if (expectedVersion !== void 0) {
|
|
64
150
|
const existing = await this.get(vault, collection, id);
|
|
65
151
|
if (existing && existing._v !== expectedVersion) {
|
|
66
|
-
throw new
|
|
152
|
+
throw new import_hub2.ConflictError(existing._v, `Version conflict: expected ${expectedVersion}, found ${existing._v}`);
|
|
67
153
|
}
|
|
68
154
|
}
|
|
69
|
-
await client.send(new
|
|
155
|
+
await client.send(new import_client_s32.PutObjectCommand({
|
|
70
156
|
Bucket: bucket,
|
|
71
157
|
Key: objectKey(vault, collection, id),
|
|
72
158
|
Body: JSON.stringify(envelope),
|
|
@@ -74,14 +160,14 @@ function s3(options) {
|
|
|
74
160
|
}));
|
|
75
161
|
},
|
|
76
162
|
async delete(vault, collection, id) {
|
|
77
|
-
await client.send(new
|
|
163
|
+
await client.send(new import_client_s32.DeleteObjectCommand({
|
|
78
164
|
Bucket: bucket,
|
|
79
165
|
Key: objectKey(vault, collection, id)
|
|
80
166
|
}));
|
|
81
167
|
},
|
|
82
168
|
async list(vault, collection) {
|
|
83
169
|
const pfx = collPrefix(vault, collection);
|
|
84
|
-
const result = await client.send(new
|
|
170
|
+
const result = await client.send(new import_client_s32.ListObjectsV2Command({
|
|
85
171
|
Bucket: bucket,
|
|
86
172
|
Prefix: pfx
|
|
87
173
|
}));
|
|
@@ -89,7 +175,7 @@ function s3(options) {
|
|
|
89
175
|
},
|
|
90
176
|
async loadAll(vault) {
|
|
91
177
|
const pfx = compPrefix(vault);
|
|
92
|
-
const listResult = await client.send(new
|
|
178
|
+
const listResult = await client.send(new import_client_s32.ListObjectsV2Command({
|
|
93
179
|
Bucket: bucket,
|
|
94
180
|
Prefix: pfx
|
|
95
181
|
}));
|
|
@@ -103,7 +189,7 @@ function s3(options) {
|
|
|
103
189
|
const collection = parts[0];
|
|
104
190
|
const id = parts[1].slice(0, -5);
|
|
105
191
|
if (collection.startsWith("_")) continue;
|
|
106
|
-
const getResult = await client.send(new
|
|
192
|
+
const getResult = await client.send(new import_client_s32.GetObjectCommand({
|
|
107
193
|
Bucket: bucket,
|
|
108
194
|
Key: key
|
|
109
195
|
}));
|
|
@@ -123,7 +209,7 @@ function s3(options) {
|
|
|
123
209
|
},
|
|
124
210
|
async ping() {
|
|
125
211
|
try {
|
|
126
|
-
await client.send(new
|
|
212
|
+
await client.send(new import_client_s32.HeadBucketCommand({ Bucket: bucket }));
|
|
127
213
|
return true;
|
|
128
214
|
} catch {
|
|
129
215
|
return false;
|
|
@@ -143,7 +229,7 @@ function s3(options) {
|
|
|
143
229
|
*/
|
|
144
230
|
async listPage(vault, collection, cursor, limit = 100) {
|
|
145
231
|
const pfx = collPrefix(vault, collection);
|
|
146
|
-
const listResult = await client.send(new
|
|
232
|
+
const listResult = await client.send(new import_client_s32.ListObjectsV2Command({
|
|
147
233
|
Bucket: bucket,
|
|
148
234
|
Prefix: pfx,
|
|
149
235
|
MaxKeys: limit,
|
|
@@ -152,7 +238,7 @@ function s3(options) {
|
|
|
152
238
|
const keys = (listResult.Contents ?? []).map((obj) => obj.Key ?? "").filter((k) => k.endsWith(".json"));
|
|
153
239
|
const items = await Promise.all(keys.map(async (key) => {
|
|
154
240
|
const id = key.slice(pfx.length, -5);
|
|
155
|
-
const getResult = await client.send(new
|
|
241
|
+
const getResult = await client.send(new import_client_s32.GetObjectCommand({
|
|
156
242
|
Bucket: bucket,
|
|
157
243
|
Key: key
|
|
158
244
|
}));
|
|
@@ -169,6 +255,7 @@ function s3(options) {
|
|
|
169
255
|
}
|
|
170
256
|
// Annotate the CommonJS export names for ESM import in node:
|
|
171
257
|
0 && (module.exports = {
|
|
172
|
-
s3
|
|
258
|
+
s3,
|
|
259
|
+
s3Bundle
|
|
173
260
|
});
|
|
174
261
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/to-aws-s3** — S3 object store for NOYDB.\n *\n * Each record is stored as a JSON object at\n * `{prefix}/{vault}/{collection}/{id}.json`. The `loadAll()` method uses\n * `ListObjectsV2` to enumerate keys then fetches them in parallel.\n *\n * ## When to use\n *\n * - **Blob / attachment storage** — pair with `@noy-db/to-aws-dynamo` via\n * `routeStore({ default: dynamo(...), blobs: s3(...) })` to route\n * encrypted binary chunks to S3.\n * - **Archive tier** — configure `routeStore` age-based tiering so old\n * records migrate to S3 while hot records stay in DynamoDB.\n * - **Large vaults** — S3 has no item size limit, unlike DynamoDB's 400 KB cap.\n *\n * ## Limitations\n *\n * - **`casAtomic: false`** — S3 has no server-side conditional write on\n * arbitrary metadata. Concurrent puts may result in last-write-wins.\n * Use DynamoDB for records that need conflict-safe writes.\n * - **`loadAll()` is O(N) requests** — listing + fetching every object in a\n * vault. Suitable for vaults up to ~10K records; beyond that, prefer\n * DynamoDB for indexed stores and S3 only for append-heavy blob storage.\n *\n * ## IAM minimum permissions\n *\n * ```json\n * { \"Action\": [\"s3:GetObject\", \"s3:PutObject\", \"s3:DeleteObject\",\n * \"s3:ListBucket\"] }\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { NoydbStore, EncryptedEnvelope, VaultSnapshot } from '@noy-db/hub'\nimport { ConflictError } from '@noy-db/hub'\nimport {\n S3Client,\n GetObjectCommand,\n PutObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n HeadBucketCommand,\n} from '@aws-sdk/client-s3'\n\n/**\n * Options for `s3()`.\n *\n * Objects are stored at `{prefix}/{vault}/{collection}/{id}.json`.\n * `loadAll()` uses `ListObjectsV2` over the vault prefix followed by parallel\n * `GetObject` calls — suitable for vaults with up to ~10K records. For larger\n * vaults, use DynamoDB or pair with `routeStore` age-tiering so S3 only\n * holds archived records.\n *\n * Note: S3 does not support atomic CAS (`casAtomic: false`). Last-write-wins\n * on concurrent puts.\n */\nexport interface S3Options {\n /** S3 bucket name. */\n bucket: string\n /** Key prefix within the bucket. Default: ''. */\n prefix?: string\n /** AWS region. Used only when `client` is not provided. Default: 'us-east-1'. */\n region?: string\n /**\n * Pre-built S3Client from `@aws-sdk/client-s3`. If provided, the adapter\n * uses this client directly and ignores `region`. Useful for apps that want\n * to share a client across adapters or supply custom middleware.\n */\n client?: S3Client\n}\n\n/**\n * Create an S3 adapter.\n * Key scheme: `{prefix}/{vault}/{collection}/{id}.json`\n */\nexport function s3(options: S3Options): NoydbStore {\n const { bucket, prefix = '' } = options\n\n const client = options.client ?? new S3Client({\n ...(options.region ? { region: options.region } : {}),\n })\n\n function objectKey(vault: string, collection: string, id: string): string {\n const parts = [vault, collection, `${id}.json`]\n return prefix ? `${prefix}/${parts.join('/')}` : parts.join('/')\n }\n\n function collPrefix(vault: string, collection: string): string {\n const parts = [vault, collection, '']\n return prefix ? `${prefix}/${parts.join('/')}` : parts.join('/')\n }\n\n function compPrefix(vault: string): string {\n return prefix ? `${prefix}/${vault}/` : `${vault}/`\n }\n\n return {\n name: 's3',\n\n async get(vault, collection, id) {\n try {\n const result = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n }))\n\n if (!result.Body) return null\n const body = await result.Body.transformToString()\n return JSON.parse(body) as EncryptedEnvelope\n } catch (err: unknown) {\n if (err instanceof Error && (err.name === 'NoSuchKey' || err.name === 'NotFound')) {\n return null\n }\n throw err\n }\n },\n\n async put(vault, collection, id, envelope, expectedVersion) {\n if (expectedVersion !== undefined) {\n const existing = await this.get(vault, collection, id)\n if (existing && existing._v !== expectedVersion) {\n throw new ConflictError(existing._v, `Version conflict: expected ${expectedVersion}, found ${existing._v}`)\n }\n }\n\n await client.send(new PutObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n Body: JSON.stringify(envelope),\n ContentType: 'application/json',\n }))\n },\n\n async delete(vault, collection, id) {\n await client.send(new DeleteObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n }))\n },\n\n async list(vault, collection) {\n const pfx = collPrefix(vault, collection)\n const result = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n }))\n\n return (result.Contents ?? [])\n .map(obj => obj.Key ?? '')\n .filter(k => k.endsWith('.json'))\n .map(k => k.slice(pfx.length, -5))\n },\n\n async loadAll(vault) {\n const pfx = compPrefix(vault)\n const listResult = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n }))\n\n const snapshot: VaultSnapshot = {}\n\n for (const obj of listResult.Contents ?? []) {\n const key = obj.Key ?? ''\n if (!key.endsWith('.json')) continue\n\n const relativePath = key.slice(pfx.length)\n const parts = relativePath.split('/')\n if (parts.length !== 2) continue\n\n const collection = parts[0]!\n const id = parts[1]!.slice(0, -5)\n if (collection.startsWith('_')) continue\n\n const getResult = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: key,\n }))\n\n if (!getResult.Body) continue\n const body = await getResult.Body.transformToString()\n\n if (!snapshot[collection]) snapshot[collection] = {}\n snapshot[collection][id] = JSON.parse(body) as EncryptedEnvelope\n }\n\n return snapshot\n },\n\n async saveAll(vault, data) {\n for (const [collection, records] of Object.entries(data)) {\n for (const [id, envelope] of Object.entries(records)) {\n await this.put(vault, collection, id, envelope)\n }\n }\n },\n\n async ping() {\n try {\n await client.send(new HeadBucketCommand({ Bucket: bucket }))\n return true\n } catch {\n return false\n }\n },\n\n /**\n * Paginate over a collection using S3's native `ContinuationToken`.\n *\n * Each page does:\n * 1. ListObjectsV2 with MaxKeys = limit and the previous token\n * 2. GetObject for every key on the page (in parallel)\n *\n * The 2-step pattern is necessary because S3 list responses don't\n * include object bodies. For very large collections this is N+1 — but\n * the parallel GETs amortize well, and consumers willing to pay for\n * stronger pagination should use a different adapter (Dynamo).\n */\n async listPage(vault, collection, cursor, limit = 100) {\n const pfx = collPrefix(vault, collection)\n const listResult = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n MaxKeys: limit,\n ...(cursor ? { ContinuationToken: cursor } : {}),\n }))\n\n const keys = (listResult.Contents ?? [])\n .map(obj => obj.Key ?? '')\n .filter(k => k.endsWith('.json'))\n\n // Fetch every body in parallel — bounded by `limit` so we never\n // fan out beyond the page size.\n const items = await Promise.all(keys.map(async (key) => {\n const id = key.slice(pfx.length, -5)\n const getResult = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: key,\n }))\n if (!getResult.Body) return null\n const body = await getResult.Body.transformToString()\n return { id, envelope: JSON.parse(body) as EncryptedEnvelope }\n }))\n\n return {\n items: items.filter((x): x is { id: string; envelope: EncryptedEnvelope } => x !== null),\n nextCursor: listResult.IsTruncated && listResult.NextContinuationToken\n ? listResult.NextContinuationToken\n : null,\n }\n },\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/bundle.ts"],"sourcesContent":["/**\n * **@noy-db/to-aws-s3** — S3 object store for NOYDB.\n *\n * Each record is stored as a JSON object at\n * `{prefix}/{vault}/{collection}/{id}.json`. The `loadAll()` method uses\n * `ListObjectsV2` to enumerate keys then fetches them in parallel.\n *\n * ## When to use\n *\n * - **Blob / attachment storage** — pair with `@noy-db/to-aws-dynamo` via\n * `routeStore({ default: dynamo(...), blobs: s3(...) })` to route\n * encrypted binary chunks to S3.\n * - **Archive tier** — configure `routeStore` age-based tiering so old\n * records migrate to S3 while hot records stay in DynamoDB.\n * - **Large vaults** — S3 has no item size limit, unlike DynamoDB's 400 KB cap.\n *\n * ## Limitations\n *\n * - **`casAtomic: false`** — S3 has no server-side conditional write on\n * arbitrary metadata. Concurrent puts may result in last-write-wins.\n * Use DynamoDB for records that need conflict-safe writes.\n * - **`loadAll()` is O(N) requests** — listing + fetching every object in a\n * vault. Suitable for vaults up to ~10K records; beyond that, prefer\n * DynamoDB for indexed stores and S3 only for append-heavy blob storage.\n *\n * ## IAM minimum permissions\n *\n * ```json\n * { \"Action\": [\"s3:GetObject\", \"s3:PutObject\", \"s3:DeleteObject\",\n * \"s3:ListBucket\"] }\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { NoydbStore, EncryptedEnvelope, VaultSnapshot } from '@noy-db/hub'\nimport { ConflictError } from '@noy-db/hub'\nimport {\n S3Client,\n GetObjectCommand,\n PutObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n HeadBucketCommand,\n} from '@aws-sdk/client-s3'\n\n/**\n * Options for `s3()`.\n *\n * Objects are stored at `{prefix}/{vault}/{collection}/{id}.json`.\n * `loadAll()` uses `ListObjectsV2` over the vault prefix followed by parallel\n * `GetObject` calls — suitable for vaults with up to ~10K records. For larger\n * vaults, use DynamoDB or pair with `routeStore` age-tiering so S3 only\n * holds archived records.\n *\n * Note: S3 does not support atomic CAS (`casAtomic: false`). Last-write-wins\n * on concurrent puts.\n */\nexport interface S3Options {\n /** S3 bucket name. */\n bucket: string\n /** Key prefix within the bucket. Default: ''. */\n prefix?: string\n /** AWS region. Used only when `client` is not provided. Default: 'us-east-1'. */\n region?: string\n /**\n * Pre-built S3Client from `@aws-sdk/client-s3`. If provided, the adapter\n * uses this client directly and ignores `region`. Useful for apps that want\n * to share a client across adapters or supply custom middleware.\n */\n client?: S3Client\n}\n\n/**\n * Create an S3 adapter.\n * Key scheme: `{prefix}/{vault}/{collection}/{id}.json`\n */\nexport function s3(options: S3Options): NoydbStore {\n const { bucket, prefix = '' } = options\n\n const client = options.client ?? new S3Client({\n ...(options.region ? { region: options.region } : {}),\n })\n\n function objectKey(vault: string, collection: string, id: string): string {\n const parts = [vault, collection, `${id}.json`]\n return prefix ? `${prefix}/${parts.join('/')}` : parts.join('/')\n }\n\n function collPrefix(vault: string, collection: string): string {\n const parts = [vault, collection, '']\n return prefix ? `${prefix}/${parts.join('/')}` : parts.join('/')\n }\n\n function compPrefix(vault: string): string {\n return prefix ? `${prefix}/${vault}/` : `${vault}/`\n }\n\n return {\n name: 's3',\n\n async get(vault, collection, id) {\n try {\n const result = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n }))\n\n if (!result.Body) return null\n const body = await result.Body.transformToString()\n return JSON.parse(body) as EncryptedEnvelope\n } catch (err: unknown) {\n if (err instanceof Error && (err.name === 'NoSuchKey' || err.name === 'NotFound')) {\n return null\n }\n throw err\n }\n },\n\n async put(vault, collection, id, envelope, expectedVersion) {\n if (expectedVersion !== undefined) {\n const existing = await this.get(vault, collection, id)\n if (existing && existing._v !== expectedVersion) {\n throw new ConflictError(existing._v, `Version conflict: expected ${expectedVersion}, found ${existing._v}`)\n }\n }\n\n await client.send(new PutObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n Body: JSON.stringify(envelope),\n ContentType: 'application/json',\n }))\n },\n\n async delete(vault, collection, id) {\n await client.send(new DeleteObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n }))\n },\n\n async list(vault, collection) {\n const pfx = collPrefix(vault, collection)\n const result = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n }))\n\n return (result.Contents ?? [])\n .map(obj => obj.Key ?? '')\n .filter(k => k.endsWith('.json'))\n .map(k => k.slice(pfx.length, -5))\n },\n\n async loadAll(vault) {\n const pfx = compPrefix(vault)\n const listResult = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n }))\n\n const snapshot: VaultSnapshot = {}\n\n for (const obj of listResult.Contents ?? []) {\n const key = obj.Key ?? ''\n if (!key.endsWith('.json')) continue\n\n const relativePath = key.slice(pfx.length)\n const parts = relativePath.split('/')\n if (parts.length !== 2) continue\n\n const collection = parts[0]!\n const id = parts[1]!.slice(0, -5)\n if (collection.startsWith('_')) continue\n\n const getResult = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: key,\n }))\n\n if (!getResult.Body) continue\n const body = await getResult.Body.transformToString()\n\n if (!snapshot[collection]) snapshot[collection] = {}\n snapshot[collection][id] = JSON.parse(body) as EncryptedEnvelope\n }\n\n return snapshot\n },\n\n async saveAll(vault, data) {\n for (const [collection, records] of Object.entries(data)) {\n for (const [id, envelope] of Object.entries(records)) {\n await this.put(vault, collection, id, envelope)\n }\n }\n },\n\n async ping() {\n try {\n await client.send(new HeadBucketCommand({ Bucket: bucket }))\n return true\n } catch {\n return false\n }\n },\n\n /**\n * Paginate over a collection using S3's native `ContinuationToken`.\n *\n * Each page does:\n * 1. ListObjectsV2 with MaxKeys = limit and the previous token\n * 2. GetObject for every key on the page (in parallel)\n *\n * The 2-step pattern is necessary because S3 list responses don't\n * include object bodies. For very large collections this is N+1 — but\n * the parallel GETs amortize well, and consumers willing to pay for\n * stronger pagination should use a different adapter (Dynamo).\n */\n async listPage(vault, collection, cursor, limit = 100) {\n const pfx = collPrefix(vault, collection)\n const listResult = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n MaxKeys: limit,\n ...(cursor ? { ContinuationToken: cursor } : {}),\n }))\n\n const keys = (listResult.Contents ?? [])\n .map(obj => obj.Key ?? '')\n .filter(k => k.endsWith('.json'))\n\n // Fetch every body in parallel — bounded by `limit` so we never\n // fan out beyond the page size.\n const items = await Promise.all(keys.map(async (key) => {\n const id = key.slice(pfx.length, -5)\n const getResult = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: key,\n }))\n if (!getResult.Body) return null\n const body = await getResult.Body.transformToString()\n return { id, envelope: JSON.parse(body) as EncryptedEnvelope }\n }))\n\n return {\n items: items.filter((x): x is { id: string; envelope: EncryptedEnvelope } => x !== null),\n nextCursor: listResult.IsTruncated && listResult.NextContinuationToken\n ? listResult.NextContinuationToken\n : null,\n }\n },\n }\n}\n\nexport { s3Bundle } from './bundle.js'\nexport type { S3BundleOptions } from './bundle.js'\n","/**\n * **s3Bundle** — whole-vault bundle store for noy-db over Amazon S3.\n *\n * Implements the `NoydbBundleStore` contract (read/write/delete/list of whole\n * `.noydb` blobs) with optimistic concurrency via S3 conditional writes. Pairs\n * with `@noy-db/hub` snapshots (`withSnapshots({ store: s3Bundle(...) })`) and\n * with bundle-mode sync.\n *\n * Key scheme: `{prefix}/{vaultId}.noydb`. The version token is the object ETag.\n *\n * **OCC:** `writeBundle(id, bytes, expectedVersion)` —\n * - `expectedVersion === null` → unconditional `PutObject` (first write / rolling overwrite).\n * - `expectedVersion = <etag>` → `PutObject` with `IfMatch`; a 412 becomes `BundleVersionConflictError`.\n *\n * Requires `@aws-sdk/client-s3` ≥ 3.696 (conditional-write `IfMatch` on PutObject, GA Nov 2024).\n *\n * @packageDocumentation\n */\nimport type { NoydbBundleStore } from '@noy-db/hub'\nimport { BundleVersionConflictError } from '@noy-db/hub'\nimport {\n S3Client,\n GetObjectCommand,\n PutObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n HeadObjectCommand,\n} from '@aws-sdk/client-s3'\n\nexport interface S3BundleOptions {\n /** S3 bucket name. */\n bucket: string\n /** Key prefix within the bucket. Default ''. Keys are `{prefix}/{vaultId}.noydb`. */\n prefix?: string\n /** AWS region. Used only when `client` is not provided. Default 'us-east-1'. */\n region?: string\n /** Pre-built S3Client. If provided, `region` is ignored. */\n client?: S3Client\n}\n\nconst SUFFIX = '.noydb'\n\nfunction stripQuotes(etag: string | undefined): string {\n return (etag ?? '').replace(/^\"|\"$/g, '')\n}\n\nexport function s3Bundle(options: S3BundleOptions): NoydbBundleStore {\n const { bucket, prefix = '' } = options\n const client = options.client ?? new S3Client({\n ...(options.region ? { region: options.region } : {}),\n })\n\n const listPrefix = prefix ? `${prefix}/` : ''\n function objectKey(vaultId: string): string {\n return `${listPrefix}${vaultId}${SUFFIX}`\n }\n\n return {\n kind: 'bundle',\n name: 's3',\n\n async readBundle(vaultId) {\n try {\n const res = await client.send(new GetObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }))\n if (!res.Body) return null\n const bytes = await (res.Body as { transformToByteArray: () => Promise<Uint8Array> }).transformToByteArray()\n return { bytes, version: stripQuotes(res.ETag) }\n } catch (err: unknown) {\n if (err instanceof Error && (err.name === 'NoSuchKey' || err.name === 'NotFound')) return null\n throw err\n }\n },\n\n async writeBundle(vaultId, bytes, expectedVersion) {\n try {\n const res = await client.send(new PutObjectCommand({\n Bucket: bucket,\n Key: objectKey(vaultId),\n Body: bytes,\n ContentType: 'application/octet-stream',\n ...(expectedVersion !== null ? { IfMatch: expectedVersion } : {}),\n }))\n let version = stripQuotes(res.ETag)\n if (!version) {\n const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }))\n version = stripQuotes(head.ETag)\n }\n return { version }\n } catch (err: unknown) {\n const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode\n if (err instanceof Error && (err.name === 'PreconditionFailed' || status === 412)) {\n throw new BundleVersionConflictError(\n `S3 bundle \"${vaultId}\" changed since expectedVersion=\"${expectedVersion}\".`,\n )\n }\n throw err\n }\n },\n\n async deleteBundle(vaultId) {\n await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }))\n },\n\n async listBundles() {\n const out: Array<{ vaultId: string; version: string; size: number }> = []\n let token: string | undefined\n do {\n const res = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: listPrefix,\n ...(token ? { ContinuationToken: token } : {}),\n }))\n for (const obj of res.Contents ?? []) {\n const key = obj.Key ?? ''\n if (!key.endsWith(SUFFIX)) continue\n out.push({\n vaultId: key.slice(listPrefix.length, -SUFFIX.length),\n version: stripQuotes(obj.ETag),\n size: obj.Size ?? 0,\n })\n }\n token = res.IsTruncated ? res.NextContinuationToken : undefined\n } while (token)\n return out\n },\n }\n}\n"],"mappings":"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","names":["import_hub","import_client_s3"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
|
-
import { NoydbStore } from '@noy-db/hub';
|
|
1
|
+
import { NoydbBundleStore, NoydbStore } from '@noy-db/hub';
|
|
2
2
|
import { S3Client } from '@aws-sdk/client-s3';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* **s3Bundle** — whole-vault bundle store for noy-db over Amazon S3.
|
|
6
|
+
*
|
|
7
|
+
* Implements the `NoydbBundleStore` contract (read/write/delete/list of whole
|
|
8
|
+
* `.noydb` blobs) with optimistic concurrency via S3 conditional writes. Pairs
|
|
9
|
+
* with `@noy-db/hub` snapshots (`withSnapshots({ store: s3Bundle(...) })`) and
|
|
10
|
+
* with bundle-mode sync.
|
|
11
|
+
*
|
|
12
|
+
* Key scheme: `{prefix}/{vaultId}.noydb`. The version token is the object ETag.
|
|
13
|
+
*
|
|
14
|
+
* **OCC:** `writeBundle(id, bytes, expectedVersion)` —
|
|
15
|
+
* - `expectedVersion === null` → unconditional `PutObject` (first write / rolling overwrite).
|
|
16
|
+
* - `expectedVersion = <etag>` → `PutObject` with `IfMatch`; a 412 becomes `BundleVersionConflictError`.
|
|
17
|
+
*
|
|
18
|
+
* Requires `@aws-sdk/client-s3` ≥ 3.696 (conditional-write `IfMatch` on PutObject, GA Nov 2024).
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface S3BundleOptions {
|
|
24
|
+
/** S3 bucket name. */
|
|
25
|
+
bucket: string;
|
|
26
|
+
/** Key prefix within the bucket. Default ''. Keys are `{prefix}/{vaultId}.noydb`. */
|
|
27
|
+
prefix?: string;
|
|
28
|
+
/** AWS region. Used only when `client` is not provided. Default 'us-east-1'. */
|
|
29
|
+
region?: string;
|
|
30
|
+
/** Pre-built S3Client. If provided, `region` is ignored. */
|
|
31
|
+
client?: S3Client;
|
|
32
|
+
}
|
|
33
|
+
declare function s3Bundle(options: S3BundleOptions): NoydbBundleStore;
|
|
34
|
+
|
|
4
35
|
/**
|
|
5
36
|
* **@noy-db/to-aws-s3** — S3 object store for NOYDB.
|
|
6
37
|
*
|
|
@@ -68,4 +99,4 @@ interface S3Options {
|
|
|
68
99
|
*/
|
|
69
100
|
declare function s3(options: S3Options): NoydbStore;
|
|
70
101
|
|
|
71
|
-
export { type S3Options, s3 };
|
|
102
|
+
export { type S3BundleOptions, type S3Options, s3, s3Bundle };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
|
-
import { NoydbStore } from '@noy-db/hub';
|
|
1
|
+
import { NoydbBundleStore, NoydbStore } from '@noy-db/hub';
|
|
2
2
|
import { S3Client } from '@aws-sdk/client-s3';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* **s3Bundle** — whole-vault bundle store for noy-db over Amazon S3.
|
|
6
|
+
*
|
|
7
|
+
* Implements the `NoydbBundleStore` contract (read/write/delete/list of whole
|
|
8
|
+
* `.noydb` blobs) with optimistic concurrency via S3 conditional writes. Pairs
|
|
9
|
+
* with `@noy-db/hub` snapshots (`withSnapshots({ store: s3Bundle(...) })`) and
|
|
10
|
+
* with bundle-mode sync.
|
|
11
|
+
*
|
|
12
|
+
* Key scheme: `{prefix}/{vaultId}.noydb`. The version token is the object ETag.
|
|
13
|
+
*
|
|
14
|
+
* **OCC:** `writeBundle(id, bytes, expectedVersion)` —
|
|
15
|
+
* - `expectedVersion === null` → unconditional `PutObject` (first write / rolling overwrite).
|
|
16
|
+
* - `expectedVersion = <etag>` → `PutObject` with `IfMatch`; a 412 becomes `BundleVersionConflictError`.
|
|
17
|
+
*
|
|
18
|
+
* Requires `@aws-sdk/client-s3` ≥ 3.696 (conditional-write `IfMatch` on PutObject, GA Nov 2024).
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface S3BundleOptions {
|
|
24
|
+
/** S3 bucket name. */
|
|
25
|
+
bucket: string;
|
|
26
|
+
/** Key prefix within the bucket. Default ''. Keys are `{prefix}/{vaultId}.noydb`. */
|
|
27
|
+
prefix?: string;
|
|
28
|
+
/** AWS region. Used only when `client` is not provided. Default 'us-east-1'. */
|
|
29
|
+
region?: string;
|
|
30
|
+
/** Pre-built S3Client. If provided, `region` is ignored. */
|
|
31
|
+
client?: S3Client;
|
|
32
|
+
}
|
|
33
|
+
declare function s3Bundle(options: S3BundleOptions): NoydbBundleStore;
|
|
34
|
+
|
|
4
35
|
/**
|
|
5
36
|
* **@noy-db/to-aws-s3** — S3 object store for NOYDB.
|
|
6
37
|
*
|
|
@@ -68,4 +99,4 @@ interface S3Options {
|
|
|
68
99
|
*/
|
|
69
100
|
declare function s3(options: S3Options): NoydbStore;
|
|
70
101
|
|
|
71
|
-
export { type S3Options, s3 };
|
|
102
|
+
export { type S3BundleOptions, type S3Options, s3, s3Bundle };
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,110 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { ConflictError } from "@noy-db/hub";
|
|
3
|
+
import {
|
|
4
|
+
S3Client as S3Client2,
|
|
5
|
+
GetObjectCommand as GetObjectCommand2,
|
|
6
|
+
PutObjectCommand as PutObjectCommand2,
|
|
7
|
+
DeleteObjectCommand as DeleteObjectCommand2,
|
|
8
|
+
ListObjectsV2Command as ListObjectsV2Command2,
|
|
9
|
+
HeadBucketCommand
|
|
10
|
+
} from "@aws-sdk/client-s3";
|
|
11
|
+
|
|
12
|
+
// src/bundle.ts
|
|
13
|
+
import { BundleVersionConflictError } from "@noy-db/hub";
|
|
3
14
|
import {
|
|
4
15
|
S3Client,
|
|
5
16
|
GetObjectCommand,
|
|
6
17
|
PutObjectCommand,
|
|
7
18
|
DeleteObjectCommand,
|
|
8
19
|
ListObjectsV2Command,
|
|
9
|
-
|
|
20
|
+
HeadObjectCommand
|
|
10
21
|
} from "@aws-sdk/client-s3";
|
|
11
|
-
|
|
22
|
+
var SUFFIX = ".noydb";
|
|
23
|
+
function stripQuotes(etag) {
|
|
24
|
+
return (etag ?? "").replace(/^"|"$/g, "");
|
|
25
|
+
}
|
|
26
|
+
function s3Bundle(options) {
|
|
12
27
|
const { bucket, prefix = "" } = options;
|
|
13
28
|
const client = options.client ?? new S3Client({
|
|
14
29
|
...options.region ? { region: options.region } : {}
|
|
15
30
|
});
|
|
31
|
+
const listPrefix = prefix ? `${prefix}/` : "";
|
|
32
|
+
function objectKey(vaultId) {
|
|
33
|
+
return `${listPrefix}${vaultId}${SUFFIX}`;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
kind: "bundle",
|
|
37
|
+
name: "s3",
|
|
38
|
+
async readBundle(vaultId) {
|
|
39
|
+
try {
|
|
40
|
+
const res = await client.send(new GetObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }));
|
|
41
|
+
if (!res.Body) return null;
|
|
42
|
+
const bytes = await res.Body.transformToByteArray();
|
|
43
|
+
return { bytes, version: stripQuotes(res.ETag) };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err instanceof Error && (err.name === "NoSuchKey" || err.name === "NotFound")) return null;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async writeBundle(vaultId, bytes, expectedVersion) {
|
|
50
|
+
try {
|
|
51
|
+
const res = await client.send(new PutObjectCommand({
|
|
52
|
+
Bucket: bucket,
|
|
53
|
+
Key: objectKey(vaultId),
|
|
54
|
+
Body: bytes,
|
|
55
|
+
ContentType: "application/octet-stream",
|
|
56
|
+
...expectedVersion !== null ? { IfMatch: expectedVersion } : {}
|
|
57
|
+
}));
|
|
58
|
+
let version = stripQuotes(res.ETag);
|
|
59
|
+
if (!version) {
|
|
60
|
+
const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }));
|
|
61
|
+
version = stripQuotes(head.ETag);
|
|
62
|
+
}
|
|
63
|
+
return { version };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const status = err.$metadata?.httpStatusCode;
|
|
66
|
+
if (err instanceof Error && (err.name === "PreconditionFailed" || status === 412)) {
|
|
67
|
+
throw new BundleVersionConflictError(
|
|
68
|
+
`S3 bundle "${vaultId}" changed since expectedVersion="${expectedVersion}".`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
async deleteBundle(vaultId) {
|
|
75
|
+
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }));
|
|
76
|
+
},
|
|
77
|
+
async listBundles() {
|
|
78
|
+
const out = [];
|
|
79
|
+
let token;
|
|
80
|
+
do {
|
|
81
|
+
const res = await client.send(new ListObjectsV2Command({
|
|
82
|
+
Bucket: bucket,
|
|
83
|
+
Prefix: listPrefix,
|
|
84
|
+
...token ? { ContinuationToken: token } : {}
|
|
85
|
+
}));
|
|
86
|
+
for (const obj of res.Contents ?? []) {
|
|
87
|
+
const key = obj.Key ?? "";
|
|
88
|
+
if (!key.endsWith(SUFFIX)) continue;
|
|
89
|
+
out.push({
|
|
90
|
+
vaultId: key.slice(listPrefix.length, -SUFFIX.length),
|
|
91
|
+
version: stripQuotes(obj.ETag),
|
|
92
|
+
size: obj.Size ?? 0
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
token = res.IsTruncated ? res.NextContinuationToken : void 0;
|
|
96
|
+
} while (token);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/index.ts
|
|
103
|
+
function s3(options) {
|
|
104
|
+
const { bucket, prefix = "" } = options;
|
|
105
|
+
const client = options.client ?? new S3Client2({
|
|
106
|
+
...options.region ? { region: options.region } : {}
|
|
107
|
+
});
|
|
16
108
|
function objectKey(vault, collection, id) {
|
|
17
109
|
const parts = [vault, collection, `${id}.json`];
|
|
18
110
|
return prefix ? `${prefix}/${parts.join("/")}` : parts.join("/");
|
|
@@ -28,7 +120,7 @@ function s3(options) {
|
|
|
28
120
|
name: "s3",
|
|
29
121
|
async get(vault, collection, id) {
|
|
30
122
|
try {
|
|
31
|
-
const result = await client.send(new
|
|
123
|
+
const result = await client.send(new GetObjectCommand2({
|
|
32
124
|
Bucket: bucket,
|
|
33
125
|
Key: objectKey(vault, collection, id)
|
|
34
126
|
}));
|
|
@@ -49,7 +141,7 @@ function s3(options) {
|
|
|
49
141
|
throw new ConflictError(existing._v, `Version conflict: expected ${expectedVersion}, found ${existing._v}`);
|
|
50
142
|
}
|
|
51
143
|
}
|
|
52
|
-
await client.send(new
|
|
144
|
+
await client.send(new PutObjectCommand2({
|
|
53
145
|
Bucket: bucket,
|
|
54
146
|
Key: objectKey(vault, collection, id),
|
|
55
147
|
Body: JSON.stringify(envelope),
|
|
@@ -57,14 +149,14 @@ function s3(options) {
|
|
|
57
149
|
}));
|
|
58
150
|
},
|
|
59
151
|
async delete(vault, collection, id) {
|
|
60
|
-
await client.send(new
|
|
152
|
+
await client.send(new DeleteObjectCommand2({
|
|
61
153
|
Bucket: bucket,
|
|
62
154
|
Key: objectKey(vault, collection, id)
|
|
63
155
|
}));
|
|
64
156
|
},
|
|
65
157
|
async list(vault, collection) {
|
|
66
158
|
const pfx = collPrefix(vault, collection);
|
|
67
|
-
const result = await client.send(new
|
|
159
|
+
const result = await client.send(new ListObjectsV2Command2({
|
|
68
160
|
Bucket: bucket,
|
|
69
161
|
Prefix: pfx
|
|
70
162
|
}));
|
|
@@ -72,7 +164,7 @@ function s3(options) {
|
|
|
72
164
|
},
|
|
73
165
|
async loadAll(vault) {
|
|
74
166
|
const pfx = compPrefix(vault);
|
|
75
|
-
const listResult = await client.send(new
|
|
167
|
+
const listResult = await client.send(new ListObjectsV2Command2({
|
|
76
168
|
Bucket: bucket,
|
|
77
169
|
Prefix: pfx
|
|
78
170
|
}));
|
|
@@ -86,7 +178,7 @@ function s3(options) {
|
|
|
86
178
|
const collection = parts[0];
|
|
87
179
|
const id = parts[1].slice(0, -5);
|
|
88
180
|
if (collection.startsWith("_")) continue;
|
|
89
|
-
const getResult = await client.send(new
|
|
181
|
+
const getResult = await client.send(new GetObjectCommand2({
|
|
90
182
|
Bucket: bucket,
|
|
91
183
|
Key: key
|
|
92
184
|
}));
|
|
@@ -126,7 +218,7 @@ function s3(options) {
|
|
|
126
218
|
*/
|
|
127
219
|
async listPage(vault, collection, cursor, limit = 100) {
|
|
128
220
|
const pfx = collPrefix(vault, collection);
|
|
129
|
-
const listResult = await client.send(new
|
|
221
|
+
const listResult = await client.send(new ListObjectsV2Command2({
|
|
130
222
|
Bucket: bucket,
|
|
131
223
|
Prefix: pfx,
|
|
132
224
|
MaxKeys: limit,
|
|
@@ -135,7 +227,7 @@ function s3(options) {
|
|
|
135
227
|
const keys = (listResult.Contents ?? []).map((obj) => obj.Key ?? "").filter((k) => k.endsWith(".json"));
|
|
136
228
|
const items = await Promise.all(keys.map(async (key) => {
|
|
137
229
|
const id = key.slice(pfx.length, -5);
|
|
138
|
-
const getResult = await client.send(new
|
|
230
|
+
const getResult = await client.send(new GetObjectCommand2({
|
|
139
231
|
Bucket: bucket,
|
|
140
232
|
Key: key
|
|
141
233
|
}));
|
|
@@ -151,6 +243,7 @@ function s3(options) {
|
|
|
151
243
|
};
|
|
152
244
|
}
|
|
153
245
|
export {
|
|
154
|
-
s3
|
|
246
|
+
s3,
|
|
247
|
+
s3Bundle
|
|
155
248
|
};
|
|
156
249
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/to-aws-s3** — S3 object store for NOYDB.\n *\n * Each record is stored as a JSON object at\n * `{prefix}/{vault}/{collection}/{id}.json`. The `loadAll()` method uses\n * `ListObjectsV2` to enumerate keys then fetches them in parallel.\n *\n * ## When to use\n *\n * - **Blob / attachment storage** — pair with `@noy-db/to-aws-dynamo` via\n * `routeStore({ default: dynamo(...), blobs: s3(...) })` to route\n * encrypted binary chunks to S3.\n * - **Archive tier** — configure `routeStore` age-based tiering so old\n * records migrate to S3 while hot records stay in DynamoDB.\n * - **Large vaults** — S3 has no item size limit, unlike DynamoDB's 400 KB cap.\n *\n * ## Limitations\n *\n * - **`casAtomic: false`** — S3 has no server-side conditional write on\n * arbitrary metadata. Concurrent puts may result in last-write-wins.\n * Use DynamoDB for records that need conflict-safe writes.\n * - **`loadAll()` is O(N) requests** — listing + fetching every object in a\n * vault. Suitable for vaults up to ~10K records; beyond that, prefer\n * DynamoDB for indexed stores and S3 only for append-heavy blob storage.\n *\n * ## IAM minimum permissions\n *\n * ```json\n * { \"Action\": [\"s3:GetObject\", \"s3:PutObject\", \"s3:DeleteObject\",\n * \"s3:ListBucket\"] }\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { NoydbStore, EncryptedEnvelope, VaultSnapshot } from '@noy-db/hub'\nimport { ConflictError } from '@noy-db/hub'\nimport {\n S3Client,\n GetObjectCommand,\n PutObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n HeadBucketCommand,\n} from '@aws-sdk/client-s3'\n\n/**\n * Options for `s3()`.\n *\n * Objects are stored at `{prefix}/{vault}/{collection}/{id}.json`.\n * `loadAll()` uses `ListObjectsV2` over the vault prefix followed by parallel\n * `GetObject` calls — suitable for vaults with up to ~10K records. For larger\n * vaults, use DynamoDB or pair with `routeStore` age-tiering so S3 only\n * holds archived records.\n *\n * Note: S3 does not support atomic CAS (`casAtomic: false`). Last-write-wins\n * on concurrent puts.\n */\nexport interface S3Options {\n /** S3 bucket name. */\n bucket: string\n /** Key prefix within the bucket. Default: ''. */\n prefix?: string\n /** AWS region. Used only when `client` is not provided. Default: 'us-east-1'. */\n region?: string\n /**\n * Pre-built S3Client from `@aws-sdk/client-s3`. If provided, the adapter\n * uses this client directly and ignores `region`. Useful for apps that want\n * to share a client across adapters or supply custom middleware.\n */\n client?: S3Client\n}\n\n/**\n * Create an S3 adapter.\n * Key scheme: `{prefix}/{vault}/{collection}/{id}.json`\n */\nexport function s3(options: S3Options): NoydbStore {\n const { bucket, prefix = '' } = options\n\n const client = options.client ?? new S3Client({\n ...(options.region ? { region: options.region } : {}),\n })\n\n function objectKey(vault: string, collection: string, id: string): string {\n const parts = [vault, collection, `${id}.json`]\n return prefix ? `${prefix}/${parts.join('/')}` : parts.join('/')\n }\n\n function collPrefix(vault: string, collection: string): string {\n const parts = [vault, collection, '']\n return prefix ? `${prefix}/${parts.join('/')}` : parts.join('/')\n }\n\n function compPrefix(vault: string): string {\n return prefix ? `${prefix}/${vault}/` : `${vault}/`\n }\n\n return {\n name: 's3',\n\n async get(vault, collection, id) {\n try {\n const result = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n }))\n\n if (!result.Body) return null\n const body = await result.Body.transformToString()\n return JSON.parse(body) as EncryptedEnvelope\n } catch (err: unknown) {\n if (err instanceof Error && (err.name === 'NoSuchKey' || err.name === 'NotFound')) {\n return null\n }\n throw err\n }\n },\n\n async put(vault, collection, id, envelope, expectedVersion) {\n if (expectedVersion !== undefined) {\n const existing = await this.get(vault, collection, id)\n if (existing && existing._v !== expectedVersion) {\n throw new ConflictError(existing._v, `Version conflict: expected ${expectedVersion}, found ${existing._v}`)\n }\n }\n\n await client.send(new PutObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n Body: JSON.stringify(envelope),\n ContentType: 'application/json',\n }))\n },\n\n async delete(vault, collection, id) {\n await client.send(new DeleteObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n }))\n },\n\n async list(vault, collection) {\n const pfx = collPrefix(vault, collection)\n const result = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n }))\n\n return (result.Contents ?? [])\n .map(obj => obj.Key ?? '')\n .filter(k => k.endsWith('.json'))\n .map(k => k.slice(pfx.length, -5))\n },\n\n async loadAll(vault) {\n const pfx = compPrefix(vault)\n const listResult = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n }))\n\n const snapshot: VaultSnapshot = {}\n\n for (const obj of listResult.Contents ?? []) {\n const key = obj.Key ?? ''\n if (!key.endsWith('.json')) continue\n\n const relativePath = key.slice(pfx.length)\n const parts = relativePath.split('/')\n if (parts.length !== 2) continue\n\n const collection = parts[0]!\n const id = parts[1]!.slice(0, -5)\n if (collection.startsWith('_')) continue\n\n const getResult = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: key,\n }))\n\n if (!getResult.Body) continue\n const body = await getResult.Body.transformToString()\n\n if (!snapshot[collection]) snapshot[collection] = {}\n snapshot[collection][id] = JSON.parse(body) as EncryptedEnvelope\n }\n\n return snapshot\n },\n\n async saveAll(vault, data) {\n for (const [collection, records] of Object.entries(data)) {\n for (const [id, envelope] of Object.entries(records)) {\n await this.put(vault, collection, id, envelope)\n }\n }\n },\n\n async ping() {\n try {\n await client.send(new HeadBucketCommand({ Bucket: bucket }))\n return true\n } catch {\n return false\n }\n },\n\n /**\n * Paginate over a collection using S3's native `ContinuationToken`.\n *\n * Each page does:\n * 1. ListObjectsV2 with MaxKeys = limit and the previous token\n * 2. GetObject for every key on the page (in parallel)\n *\n * The 2-step pattern is necessary because S3 list responses don't\n * include object bodies. For very large collections this is N+1 — but\n * the parallel GETs amortize well, and consumers willing to pay for\n * stronger pagination should use a different adapter (Dynamo).\n */\n async listPage(vault, collection, cursor, limit = 100) {\n const pfx = collPrefix(vault, collection)\n const listResult = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n MaxKeys: limit,\n ...(cursor ? { ContinuationToken: cursor } : {}),\n }))\n\n const keys = (listResult.Contents ?? [])\n .map(obj => obj.Key ?? '')\n .filter(k => k.endsWith('.json'))\n\n // Fetch every body in parallel — bounded by `limit` so we never\n // fan out beyond the page size.\n const items = await Promise.all(keys.map(async (key) => {\n const id = key.slice(pfx.length, -5)\n const getResult = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: key,\n }))\n if (!getResult.Body) return null\n const body = await getResult.Body.transformToString()\n return { id, envelope: JSON.parse(body) as EncryptedEnvelope }\n }))\n\n return {\n items: items.filter((x): x is { id: string; envelope: EncryptedEnvelope } => x !== null),\n nextCursor: listResult.IsTruncated && listResult.NextContinuationToken\n ? listResult.NextContinuationToken\n : null,\n }\n },\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/bundle.ts"],"sourcesContent":["/**\n * **@noy-db/to-aws-s3** — S3 object store for NOYDB.\n *\n * Each record is stored as a JSON object at\n * `{prefix}/{vault}/{collection}/{id}.json`. The `loadAll()` method uses\n * `ListObjectsV2` to enumerate keys then fetches them in parallel.\n *\n * ## When to use\n *\n * - **Blob / attachment storage** — pair with `@noy-db/to-aws-dynamo` via\n * `routeStore({ default: dynamo(...), blobs: s3(...) })` to route\n * encrypted binary chunks to S3.\n * - **Archive tier** — configure `routeStore` age-based tiering so old\n * records migrate to S3 while hot records stay in DynamoDB.\n * - **Large vaults** — S3 has no item size limit, unlike DynamoDB's 400 KB cap.\n *\n * ## Limitations\n *\n * - **`casAtomic: false`** — S3 has no server-side conditional write on\n * arbitrary metadata. Concurrent puts may result in last-write-wins.\n * Use DynamoDB for records that need conflict-safe writes.\n * - **`loadAll()` is O(N) requests** — listing + fetching every object in a\n * vault. Suitable for vaults up to ~10K records; beyond that, prefer\n * DynamoDB for indexed stores and S3 only for append-heavy blob storage.\n *\n * ## IAM minimum permissions\n *\n * ```json\n * { \"Action\": [\"s3:GetObject\", \"s3:PutObject\", \"s3:DeleteObject\",\n * \"s3:ListBucket\"] }\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { NoydbStore, EncryptedEnvelope, VaultSnapshot } from '@noy-db/hub'\nimport { ConflictError } from '@noy-db/hub'\nimport {\n S3Client,\n GetObjectCommand,\n PutObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n HeadBucketCommand,\n} from '@aws-sdk/client-s3'\n\n/**\n * Options for `s3()`.\n *\n * Objects are stored at `{prefix}/{vault}/{collection}/{id}.json`.\n * `loadAll()` uses `ListObjectsV2` over the vault prefix followed by parallel\n * `GetObject` calls — suitable for vaults with up to ~10K records. For larger\n * vaults, use DynamoDB or pair with `routeStore` age-tiering so S3 only\n * holds archived records.\n *\n * Note: S3 does not support atomic CAS (`casAtomic: false`). Last-write-wins\n * on concurrent puts.\n */\nexport interface S3Options {\n /** S3 bucket name. */\n bucket: string\n /** Key prefix within the bucket. Default: ''. */\n prefix?: string\n /** AWS region. Used only when `client` is not provided. Default: 'us-east-1'. */\n region?: string\n /**\n * Pre-built S3Client from `@aws-sdk/client-s3`. If provided, the adapter\n * uses this client directly and ignores `region`. Useful for apps that want\n * to share a client across adapters or supply custom middleware.\n */\n client?: S3Client\n}\n\n/**\n * Create an S3 adapter.\n * Key scheme: `{prefix}/{vault}/{collection}/{id}.json`\n */\nexport function s3(options: S3Options): NoydbStore {\n const { bucket, prefix = '' } = options\n\n const client = options.client ?? new S3Client({\n ...(options.region ? { region: options.region } : {}),\n })\n\n function objectKey(vault: string, collection: string, id: string): string {\n const parts = [vault, collection, `${id}.json`]\n return prefix ? `${prefix}/${parts.join('/')}` : parts.join('/')\n }\n\n function collPrefix(vault: string, collection: string): string {\n const parts = [vault, collection, '']\n return prefix ? `${prefix}/${parts.join('/')}` : parts.join('/')\n }\n\n function compPrefix(vault: string): string {\n return prefix ? `${prefix}/${vault}/` : `${vault}/`\n }\n\n return {\n name: 's3',\n\n async get(vault, collection, id) {\n try {\n const result = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n }))\n\n if (!result.Body) return null\n const body = await result.Body.transformToString()\n return JSON.parse(body) as EncryptedEnvelope\n } catch (err: unknown) {\n if (err instanceof Error && (err.name === 'NoSuchKey' || err.name === 'NotFound')) {\n return null\n }\n throw err\n }\n },\n\n async put(vault, collection, id, envelope, expectedVersion) {\n if (expectedVersion !== undefined) {\n const existing = await this.get(vault, collection, id)\n if (existing && existing._v !== expectedVersion) {\n throw new ConflictError(existing._v, `Version conflict: expected ${expectedVersion}, found ${existing._v}`)\n }\n }\n\n await client.send(new PutObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n Body: JSON.stringify(envelope),\n ContentType: 'application/json',\n }))\n },\n\n async delete(vault, collection, id) {\n await client.send(new DeleteObjectCommand({\n Bucket: bucket,\n Key: objectKey(vault, collection, id),\n }))\n },\n\n async list(vault, collection) {\n const pfx = collPrefix(vault, collection)\n const result = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n }))\n\n return (result.Contents ?? [])\n .map(obj => obj.Key ?? '')\n .filter(k => k.endsWith('.json'))\n .map(k => k.slice(pfx.length, -5))\n },\n\n async loadAll(vault) {\n const pfx = compPrefix(vault)\n const listResult = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n }))\n\n const snapshot: VaultSnapshot = {}\n\n for (const obj of listResult.Contents ?? []) {\n const key = obj.Key ?? ''\n if (!key.endsWith('.json')) continue\n\n const relativePath = key.slice(pfx.length)\n const parts = relativePath.split('/')\n if (parts.length !== 2) continue\n\n const collection = parts[0]!\n const id = parts[1]!.slice(0, -5)\n if (collection.startsWith('_')) continue\n\n const getResult = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: key,\n }))\n\n if (!getResult.Body) continue\n const body = await getResult.Body.transformToString()\n\n if (!snapshot[collection]) snapshot[collection] = {}\n snapshot[collection][id] = JSON.parse(body) as EncryptedEnvelope\n }\n\n return snapshot\n },\n\n async saveAll(vault, data) {\n for (const [collection, records] of Object.entries(data)) {\n for (const [id, envelope] of Object.entries(records)) {\n await this.put(vault, collection, id, envelope)\n }\n }\n },\n\n async ping() {\n try {\n await client.send(new HeadBucketCommand({ Bucket: bucket }))\n return true\n } catch {\n return false\n }\n },\n\n /**\n * Paginate over a collection using S3's native `ContinuationToken`.\n *\n * Each page does:\n * 1. ListObjectsV2 with MaxKeys = limit and the previous token\n * 2. GetObject for every key on the page (in parallel)\n *\n * The 2-step pattern is necessary because S3 list responses don't\n * include object bodies. For very large collections this is N+1 — but\n * the parallel GETs amortize well, and consumers willing to pay for\n * stronger pagination should use a different adapter (Dynamo).\n */\n async listPage(vault, collection, cursor, limit = 100) {\n const pfx = collPrefix(vault, collection)\n const listResult = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: pfx,\n MaxKeys: limit,\n ...(cursor ? { ContinuationToken: cursor } : {}),\n }))\n\n const keys = (listResult.Contents ?? [])\n .map(obj => obj.Key ?? '')\n .filter(k => k.endsWith('.json'))\n\n // Fetch every body in parallel — bounded by `limit` so we never\n // fan out beyond the page size.\n const items = await Promise.all(keys.map(async (key) => {\n const id = key.slice(pfx.length, -5)\n const getResult = await client.send(new GetObjectCommand({\n Bucket: bucket,\n Key: key,\n }))\n if (!getResult.Body) return null\n const body = await getResult.Body.transformToString()\n return { id, envelope: JSON.parse(body) as EncryptedEnvelope }\n }))\n\n return {\n items: items.filter((x): x is { id: string; envelope: EncryptedEnvelope } => x !== null),\n nextCursor: listResult.IsTruncated && listResult.NextContinuationToken\n ? listResult.NextContinuationToken\n : null,\n }\n },\n }\n}\n\nexport { s3Bundle } from './bundle.js'\nexport type { S3BundleOptions } from './bundle.js'\n","/**\n * **s3Bundle** — whole-vault bundle store for noy-db over Amazon S3.\n *\n * Implements the `NoydbBundleStore` contract (read/write/delete/list of whole\n * `.noydb` blobs) with optimistic concurrency via S3 conditional writes. Pairs\n * with `@noy-db/hub` snapshots (`withSnapshots({ store: s3Bundle(...) })`) and\n * with bundle-mode sync.\n *\n * Key scheme: `{prefix}/{vaultId}.noydb`. The version token is the object ETag.\n *\n * **OCC:** `writeBundle(id, bytes, expectedVersion)` —\n * - `expectedVersion === null` → unconditional `PutObject` (first write / rolling overwrite).\n * - `expectedVersion = <etag>` → `PutObject` with `IfMatch`; a 412 becomes `BundleVersionConflictError`.\n *\n * Requires `@aws-sdk/client-s3` ≥ 3.696 (conditional-write `IfMatch` on PutObject, GA Nov 2024).\n *\n * @packageDocumentation\n */\nimport type { NoydbBundleStore } from '@noy-db/hub'\nimport { BundleVersionConflictError } from '@noy-db/hub'\nimport {\n S3Client,\n GetObjectCommand,\n PutObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n HeadObjectCommand,\n} from '@aws-sdk/client-s3'\n\nexport interface S3BundleOptions {\n /** S3 bucket name. */\n bucket: string\n /** Key prefix within the bucket. Default ''. Keys are `{prefix}/{vaultId}.noydb`. */\n prefix?: string\n /** AWS region. Used only when `client` is not provided. Default 'us-east-1'. */\n region?: string\n /** Pre-built S3Client. If provided, `region` is ignored. */\n client?: S3Client\n}\n\nconst SUFFIX = '.noydb'\n\nfunction stripQuotes(etag: string | undefined): string {\n return (etag ?? '').replace(/^\"|\"$/g, '')\n}\n\nexport function s3Bundle(options: S3BundleOptions): NoydbBundleStore {\n const { bucket, prefix = '' } = options\n const client = options.client ?? new S3Client({\n ...(options.region ? { region: options.region } : {}),\n })\n\n const listPrefix = prefix ? `${prefix}/` : ''\n function objectKey(vaultId: string): string {\n return `${listPrefix}${vaultId}${SUFFIX}`\n }\n\n return {\n kind: 'bundle',\n name: 's3',\n\n async readBundle(vaultId) {\n try {\n const res = await client.send(new GetObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }))\n if (!res.Body) return null\n const bytes = await (res.Body as { transformToByteArray: () => Promise<Uint8Array> }).transformToByteArray()\n return { bytes, version: stripQuotes(res.ETag) }\n } catch (err: unknown) {\n if (err instanceof Error && (err.name === 'NoSuchKey' || err.name === 'NotFound')) return null\n throw err\n }\n },\n\n async writeBundle(vaultId, bytes, expectedVersion) {\n try {\n const res = await client.send(new PutObjectCommand({\n Bucket: bucket,\n Key: objectKey(vaultId),\n Body: bytes,\n ContentType: 'application/octet-stream',\n ...(expectedVersion !== null ? { IfMatch: expectedVersion } : {}),\n }))\n let version = stripQuotes(res.ETag)\n if (!version) {\n const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }))\n version = stripQuotes(head.ETag)\n }\n return { version }\n } catch (err: unknown) {\n const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode\n if (err instanceof Error && (err.name === 'PreconditionFailed' || status === 412)) {\n throw new BundleVersionConflictError(\n `S3 bundle \"${vaultId}\" changed since expectedVersion=\"${expectedVersion}\".`,\n )\n }\n throw err\n }\n },\n\n async deleteBundle(vaultId) {\n await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: objectKey(vaultId) }))\n },\n\n async listBundles() {\n const out: Array<{ vaultId: string; version: string; size: number }> = []\n let token: string | undefined\n do {\n const res = await client.send(new ListObjectsV2Command({\n Bucket: bucket,\n Prefix: listPrefix,\n ...(token ? { ContinuationToken: token } : {}),\n }))\n for (const obj of res.Contents ?? []) {\n const key = obj.Key ?? ''\n if (!key.endsWith(SUFFIX)) continue\n out.push({\n vaultId: key.slice(listPrefix.length, -SUFFIX.length),\n version: stripQuotes(obj.ETag),\n size: obj.Size ?? 0,\n })\n }\n token = res.IsTruncated ? res.NextContinuationToken : undefined\n } while (token)\n return out\n },\n }\n}\n"],"mappings":"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","names":["S3Client","GetObjectCommand","PutObjectCommand","DeleteObjectCommand","ListObjectsV2Command","S3Client","GetObjectCommand","PutObjectCommand","DeleteObjectCommand","ListObjectsV2Command"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noy-db/to-aws-s3",
|
|
3
|
-
"version": "0.2.0-pre.
|
|
3
|
+
"version": "0.2.0-pre.11",
|
|
4
4
|
"description": "AWS S3 adapter for noy-db — encrypted object storage with zero-knowledge cloud sync",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "vLannaAi <vicio@lanna.ai>",
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@aws-sdk/client-s3": "^3.0.0",
|
|
43
|
-
"@noy-db/hub": "0.2.0-pre.
|
|
43
|
+
"@noy-db/hub": "0.2.0-pre.11"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@aws-sdk/client-s3": "^3.0.0",
|
|
47
|
-
"@noy-db/hub": "0.2.0-pre.
|
|
47
|
+
"@noy-db/hub": "0.2.0-pre.11"
|
|
48
48
|
},
|
|
49
49
|
"keywords": [
|
|
50
50
|
"noy-db",
|