@tandem-language-exchange/content-store 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,335 @@
1
+ # Content Store
2
+
3
+ Syncs content from **Contentful** and **Sanity** into versioned JSON bundles on **S3**. Provides both a CLI for one-off or scheduled syncs and an Express REST API for on-demand triggering.
4
+
5
+ ## Architecture
6
+
7
+ The codebase is split into three areas so that the npm-published SDK never ships server dependencies (Express, Contentful SDK, Sanity client, dotenv, etc.):
8
+
9
+ ```
10
+ src/
11
+ ├── shared/ # Shared between SDK and server
12
+ │ ├── types.ts # S3Config, CMSProvider
13
+ │ └── s3.ts # ContentStore class (S3 operations)
14
+ ├── sdk/ # npm package surface — importable by consumers
15
+ │ ├── client.ts # ContentStoreSDK (fetchBundles + queryBundle)
16
+ │ ├── index.ts # SDK entry point + re-exports
17
+ │ └── README.md # SDK usage documentation
18
+ ├── server/ # Express REST API + CLI + CMS sync engine
19
+ │ ├── config.ts # Env-based runtime configuration
20
+ │ ├── server.ts # Express app + endpoints
21
+ │ ├── cli.ts # Commander CLI
22
+ │ ├── middleware/
23
+ │ │ └── auth.ts # Bearer token auth
24
+ │ ├── adapters/ # CMS adapter pattern
25
+ │ │ ├── types.ts # CMSAdapter interface
26
+ │ │ ├── contentful.ts # Contentful (batched pagination + envelope stripping)
27
+ │ │ ├── sanity.ts # Sanity (GROQ queries)
28
+ │ │ └── index.ts # createAdapter factory
29
+ │ └── sync/
30
+ │ ├── retry.ts # Exponential backoff with jitter & 429 handling
31
+ │ └── engine.ts # Orchestrates fetch → upload → copy-latest
32
+ └── index.ts # npm entry — re-exports SDK + shared only
33
+ ```
34
+
35
+ When published to npm, only `dist/index.*`, `dist/sdk/`, and `dist/shared/` are included (controlled by the `files` field in `package.json`). Everything under `dist/server/` is excluded.
36
+
37
+ ### S3 Object Naming
38
+
39
+ Each sync produces two objects per content type:
40
+
41
+ | Object | Purpose |
42
+ | --- | --- |
43
+ | `content-{cms}-{contentType}-{timestamp}.json` | Versioned snapshot (retained indefinitely) |
44
+ | `content-{cms}-{contentType}.json` | Latest pointer (overwritten on each sync) |
45
+
46
+ For example, syncing the `gridLayout` type from Contentful at Unix timestamp `1743400000` creates:
47
+
48
+ ```
49
+ content-contentful-gridLayout-1743400000.json ← versioned
50
+ content-contentful-gridLayout.json ← latest (copy of above)
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Getting Started Locally
56
+
57
+ ### 1. Clone this repo from Azure DevOps
58
+
59
+ ```bash
60
+ git clone git@ssh.dev.azure.com:v3/tripod-technology/content-store/content-store
61
+ cd content-store
62
+ ```
63
+
64
+ ### 2. Setup NPM Auth
65
+
66
+ The project depends on the private `@tandem-web/web-azure-appconfig-sync` package. Authenticate with the npm registry before installing:
67
+
68
+ ```bash
69
+ //registry.npmjs.org/:_authToken={NPM_TOKEN}
70
+ registry=https://registry.npmjs.org/
71
+ ```
72
+
73
+ This token can be found in the Azure Pipelines:
74
+
75
+ ```
76
+ [Project] > Pipelines > Library > Variable Groups > azure-appconfig-sync > NPM_TOKEN
77
+ ```
78
+
79
+ ### 3. Install Dependencies
80
+
81
+ Requires **Node >= 24.1** and **npm >= 11.3** (see `engines` in `package.json`).
82
+
83
+ ```bash
84
+ npm install
85
+ ```
86
+
87
+ ### 4. Setup Azure App Config Syncing
88
+
89
+ This allows you to pull environment variables down from Azure App Configuration, and also switch environments easily.
90
+
91
+ You need to create a file in the root of this project called `.env.appconfig` with the connection strings for Azure App Configuration for each environment:
92
+
93
+ ```.env.appconfig
94
+ AZURE_APP_CONFIG_CONNECTION_STRING_STAGING=[string]
95
+ AZURE_APP_CONFIG_CONNECTION_STRING_PRODUCTION=Endpoint=[string]
96
+ AZURE_APP_CONFIG_CONNECTION_STRING_LOCAL=Endpoint=[string]
97
+ ```
98
+
99
+ These connection strings can be found in the Azure Pipelines:
100
+
101
+ ```
102
+ [Project] > Pipelines > Library > Variable Groups > azure-appconfig-sync
103
+ ```
104
+
105
+ *See the [docs](https://github.com/viveme/web-azure-appconfig-sync) for full the config needed of this package*
106
+
107
+ Run the following command to sync the `local` config:
108
+
109
+
110
+ ```bash
111
+ npm run sync-appconfig:local
112
+ ```
113
+
114
+ Also, run the following command to sync the `staging` config:
115
+
116
+ ```bash
117
+ npm run sync-appconfig:staging
118
+ ```
119
+
120
+ ### 5. Configuring Content Types
121
+
122
+ Content types to sync from Contentful are defined directly in `src/server/config.ts`:
123
+
124
+ ```typescript
125
+ contentTypes: [
126
+ 'gridLayout',
127
+ 'iconWithText',
128
+ // Add or remove content type IDs here.
129
+ // Leave empty to sync every content type in the space.
130
+ ]
131
+ ```
132
+
133
+ Sanity syncs all document types by default (excluding internal `system.*` and `sanity.*` types). You can override this at runtime via the `--types` CLI flag or the `contentTypes` field in the REST API request body.
134
+
135
+ ---
136
+
137
+ ## Running Local Development Server
138
+
139
+ Starts the Express server with hot-reload via `tsx watch`:
140
+
141
+ ```bash
142
+ npm run dev
143
+ ```
144
+
145
+ The server listens on `http://localhost:4000` (or the port set by `PORT`).
146
+
147
+ Available endpoints:
148
+
149
+ | Method | Path | Auth | Description |
150
+ | --- | --- | --- | --- |
151
+ | `GET` | `/health` | None | Health check |
152
+ | `POST` | `/sync` | Bearer token | Trigger a content sync |
153
+
154
+ ---
155
+
156
+ ## Building & Running Locally
157
+
158
+ Compile TypeScript to `dist/`:
159
+
160
+ ```bash
161
+ npm run build
162
+ ```
163
+
164
+ Run the compiled server:
165
+
166
+ ```bash
167
+ npm start
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Docker Build
173
+
174
+ Run the following command to build the the app in a docker container locally:
175
+
176
+ ```bash
177
+ npm run build:docker
178
+ ```
179
+
180
+ CI builds are handled by Azure Pipelines (`deploy/staging-pipeline.yml` and `deploy/production-pipeline.yml`), which:
181
+
182
+ 1. Authenticate to npm and install `@tandem-web/web-azure-appconfig-sync`
183
+ 2. Fetch environment variables from Azure App Configuration into `.env`
184
+ 3. Build and tag the Docker image
185
+ 4. Push to Azure Container Registry (`tandemstaging.azurecr.io` / `tandemproduction.azurecr.io`)
186
+ 5. Force a new ECS service deployment on the `nextgen-staging` or `nextgen-production` cluster
187
+
188
+ ---
189
+
190
+ ## Running from CLI
191
+
192
+ All CLI commands are invoked through `tsx src/server/cli.ts` under the hood. Convenience npm scripts are provided for common operations.
193
+
194
+ ### `sync` — push CMS content to S3
195
+
196
+ **Sync all configured Contentful content types:**
197
+
198
+ ```bash
199
+ npm run sync:contentful
200
+ ```
201
+
202
+ **Sync all Sanity document types:**
203
+
204
+ ```bash
205
+ npm run sync:sanity
206
+ ```
207
+
208
+ **Sync specific content types only:**
209
+
210
+ ```bash
211
+ npm run sync -- --cms contentful --types gridLayout,iconWithText
212
+ npm run sync -- --cms sanity --types article,author
213
+ ```
214
+
215
+ The process exits with code `0` on full success and `1` if any content type failed to sync. A JSON summary is printed to stdout on completion.
216
+
217
+ ### `fetch` — download bundles from S3 to the local filesystem
218
+
219
+ Downloads the latest version of each requested content type bundle from S3 and writes them as JSON files to a local directory.
220
+
221
+ ```bash
222
+ npm run fetch -- --cms contentful --types gridLayout,page
223
+ ```
224
+
225
+ | Flag | Required | Default | Description |
226
+ | --- | --- | --- | --- |
227
+ | `--cms <provider>` | Yes | | `contentful` or `sanity` |
228
+ | `--types <types>` | Yes | | Comma-separated content types |
229
+ | `--output <dir>` | No | `./content-cache` | Local directory to write bundle files to |
230
+
231
+ Files are written as `content-{cms}-{contentType}.json` inside the output directory.
232
+
233
+ ### `query` — query a local bundle
234
+
235
+ Reads a previously fetched bundle from disk and prints filtered results as JSON.
236
+
237
+ ```bash
238
+ npm run query -- --cms contentful --type gridLayout \
239
+ --fields '{"columns":"2"}' \
240
+ --select title,bodyBefore \
241
+ --limit 5 \
242
+ --include 2
243
+ ```
244
+
245
+ | Flag | Required | Default | Description |
246
+ | --- | --- | --- | --- |
247
+ | `--cms <provider>` | Yes | | `contentful` or `sanity` |
248
+ | `--type <type>` | Yes | | Content type to query |
249
+ | `--output <dir>` | No | `./content-cache` | Directory where bundles are stored |
250
+ | `--fields <json>` | No | | JSON filter object (e.g. `'{"columns":"2"}'`) |
251
+ | `--select <props>` | No | | Comma-separated properties to include in results |
252
+ | `--limit <n>` | No | | Maximum number of results |
253
+ | `--include <n>` | No | | Depth of nested references to include (see [SDK README](src/sdk/README.md)) |
254
+
255
+ ---
256
+
257
+ ## Calling the REST API
258
+
259
+ All requests to `POST /sync` must include a valid bearer token in the `Authorization` header. The token is the value of the `CONTENT_STORE_API_TOKEN` environment variable.
260
+
261
+ **Sync all configured Contentful types:**
262
+
263
+ ```bash
264
+ curl -X POST http://localhost:3000/sync \
265
+ -H "Authorization: Bearer <CONTENT_STORE_API_TOKEN>" \
266
+ -H "Content-Type: application/json" \
267
+ -d '{"cms": "contentful"}'
268
+ ```
269
+
270
+ **Sync specific types:**
271
+
272
+ ```bash
273
+ curl -X POST http://localhost:3000/sync \
274
+ -H "Authorization: Bearer <CONTENT_STORE_API_TOKEN>" \
275
+ -H "Content-Type: application/json" \
276
+ -d '{"cms": "sanity", "contentTypes": ["article", "author"]}'
277
+ ```
278
+
279
+ **Response format:**
280
+
281
+ ```json
282
+ {
283
+ "cms": "contentful",
284
+ "timestamp": 1743400000,
285
+ "entries": [
286
+ {
287
+ "contentType": "gridLayout",
288
+ "itemCount": 42,
289
+ "versionedKey": "content-contentful-gridLayout-1743400000.json",
290
+ "latestKey": "content-contentful-gridLayout.json"
291
+ }
292
+ ],
293
+ "errors": []
294
+ }
295
+ ```
296
+
297
+ | Status | Meaning |
298
+ | --- | --- |
299
+ | `200` | All content types synced successfully |
300
+ | `207` | Partial success (some types failed — check `errors` array) |
301
+ | `400` | Invalid CMS provider |
302
+ | `401` | Missing or malformed Authorization header |
303
+ | `403` | Invalid bearer token |
304
+ | `500` | Unexpected server error |
305
+
306
+ **Health check (no auth required):**
307
+
308
+ ```bash
309
+ curl http://localhost:3000/health
310
+ ```
311
+
312
+ ---
313
+
314
+ ## Additional Considerations
315
+
316
+ ### Rate Limiting & Retry Behaviour
317
+
318
+ Contentful's Content Delivery/Preview API enforces rate limits. The sync engine wraps every API call in a retry loop with:
319
+
320
+ - **Exponential backoff** — delay doubles on each attempt (1 s → 2 s → 4 s → …)
321
+ - **Random jitter** — prevents thundering-herd effects when multiple syncs run concurrently
322
+ - **429 awareness** — if the API returns a `429 Too Many Requests` with an `x-contentful-ratelimit-reset` header, that value is used as the wait time instead of computed backoff
323
+ - **Configurable limits** — `RETRY_MAX_RETRIES`, `RETRY_BASE_DELAY_MS`, and `RETRY_MAX_DELAY_MS` can be tuned via env vars
324
+
325
+ ### Contentful Pagination (Batched Sync)
326
+
327
+ Contentful's `getEntries` endpoint returns a maximum of 1,000 items per request. The Contentful adapter automatically pages through the full result set using `skip` and `limit`, collecting all entries before uploading a single consolidated JSON file to S3.
328
+
329
+ ### S3 Versioning Strategy
330
+
331
+ Every sync writes a timestamped object and then copies it to the "latest" key. This means:
332
+
333
+ - Consumers that always read `content-{cms}-{type}.json` get the most recent data without knowing the timestamp.
334
+ - Older snapshots (`content-{cms}-{type}-{timestamp}.json`) remain in the bucket and can be used for auditing, rollback, or diffing.
335
+ - To manage storage costs over time, consider adding an S3 lifecycle rule to expire versioned objects older than a chosen retention period.
@@ -0,0 +1,5 @@
1
+ export { ContentStoreSDK } from './sdk/index.js';
2
+ export type { SDKConfig, FetchBundlesOptions, QueryOptions } from './sdk/index.js';
3
+ export { fetchBundles, queryBundle, trimDepth } from './shared/bundles.js';
4
+ export { ContentStore } from './shared/s3.js';
5
+ export type { CMSProvider, S3Config } from './shared/types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { ContentStoreSDK } from './sdk/index.js';
2
+ export { fetchBundles, queryBundle, trimDepth } from './shared/bundles.js';
3
+ export { ContentStore } from './shared/s3.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAEjD,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,24 @@
1
+ import type { S3Config, CMSProvider } from '../shared/types.js';
2
+ import { type FetchBundlesOptions, type QueryOptions } from '../shared/bundles.js';
3
+ export type { FetchBundlesOptions, QueryOptions };
4
+ export interface SDKConfig {
5
+ s3: S3Config;
6
+ /** Directory where bundle JSON files are saved on the local filesystem. */
7
+ outputDir: string;
8
+ }
9
+ export declare class ContentStoreSDK {
10
+ private store;
11
+ private outputDir;
12
+ constructor(config: SDKConfig);
13
+ /**
14
+ * Downloads the latest bundles from S3 and writes them as JSON files
15
+ * to `outputDir`.
16
+ *
17
+ * @returns A map of contentType to absolute file path.
18
+ */
19
+ fetchBundles(options: FetchBundlesOptions): Promise<Record<string, string>>;
20
+ /**
21
+ * Queries a previously fetched bundle from the local filesystem.
22
+ */
23
+ queryBundle(cms: CMSProvider, contentType: string, options?: QueryOptions): Promise<unknown[]>;
24
+ }
@@ -0,0 +1,26 @@
1
+ import { ContentStore } from '../shared/s3.js';
2
+ import { fetchBundles, queryBundle, } from '../shared/bundles.js';
3
+ export class ContentStoreSDK {
4
+ store;
5
+ outputDir;
6
+ constructor(config) {
7
+ this.store = new ContentStore(config.s3);
8
+ this.outputDir = config.outputDir;
9
+ }
10
+ /**
11
+ * Downloads the latest bundles from S3 and writes them as JSON files
12
+ * to `outputDir`.
13
+ *
14
+ * @returns A map of contentType to absolute file path.
15
+ */
16
+ async fetchBundles(options) {
17
+ return fetchBundles(this.store, this.outputDir, options);
18
+ }
19
+ /**
20
+ * Queries a previously fetched bundle from the local filesystem.
21
+ */
22
+ async queryBundle(cms, contentType, options = {}) {
23
+ return queryBundle(this.outputDir, cms, contentType, options);
24
+ }
25
+ }
26
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/sdk/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EACL,YAAY,EACZ,WAAW,GAGZ,MAAM,sBAAsB,CAAC;AAU9B,MAAM,OAAO,eAAe;IAClB,KAAK,CAAe;IACpB,SAAS,CAAS;IAE1B,YAAY,MAAiB;QAC3B,IAAI,CAAC,KAAK,GAAG,IAAI,YAAY,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACzC,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC,SAAS,CAAC;IACpC,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,YAAY,CAChB,OAA4B;QAE5B,OAAO,YAAY,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;IAC3D,CAAC;IAED;;OAEG;IACH,KAAK,CAAC,WAAW,CACf,GAAgB,EAChB,WAAmB,EACnB,UAAwB,EAAE;QAE1B,OAAO,WAAW,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC;IAChE,CAAC;CACF"}
@@ -0,0 +1,5 @@
1
+ export { ContentStoreSDK } from './client.js';
2
+ export type { SDKConfig, FetchBundlesOptions, QueryOptions } from './client.js';
3
+ export { fetchBundles, queryBundle, trimDepth } from '../shared/bundles.js';
4
+ export { ContentStore } from '../shared/s3.js';
5
+ export type { S3Config, CMSProvider } from '../shared/types.js';
@@ -0,0 +1,4 @@
1
+ export { ContentStoreSDK } from './client.js';
2
+ export { fetchBundles, queryBundle, trimDepth } from '../shared/bundles.js';
3
+ export { ContentStore } from '../shared/s3.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sdk/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,41 @@
1
+ import type { CMSProvider } from './types.js';
2
+ import { ContentStore } from './s3.js';
3
+ export interface FetchBundlesOptions {
4
+ cms: CMSProvider;
5
+ contentTypes: string[];
6
+ }
7
+ export interface QueryOptions {
8
+ /** Filter items by matching top-level property values (exact equality, or array for IN). */
9
+ fields?: Record<string, unknown>;
10
+ /** Properties to include in each result object. Omit to return all properties. */
11
+ select?: string[];
12
+ /** Maximum number of items to return. */
13
+ limit?: number;
14
+ /**
15
+ * How many levels deep to return.
16
+ * - 1 = the item's own scalar properties only; nested objects/refs are nulled.
17
+ * - 2 = the item including its direct references; refs inside those are nulled.
18
+ * - 3 = three levels deep, and so on.
19
+ * - Omit to return the full depth.
20
+ */
21
+ include?: number;
22
+ }
23
+ /**
24
+ * Trims nested object depth.
25
+ *
26
+ * `remaining` represents how many levels the current object is allowed.
27
+ * - Scalar properties are always kept.
28
+ * - Nested objects / arrays-of-objects consume one level. When `remaining`
29
+ * drops to 1 they are replaced with `null` (no budget left for refs).
30
+ */
31
+ export declare function trimDepth(value: unknown, remaining: number): unknown;
32
+ /**
33
+ * Downloads the latest bundles from S3 and writes them as JSON files to `outputDir`.
34
+ *
35
+ * @returns A map of contentType to absolute file path.
36
+ */
37
+ export declare function fetchBundles(store: ContentStore, outputDir: string, options: FetchBundlesOptions): Promise<Record<string, string>>;
38
+ /**
39
+ * Queries a previously fetched bundle from the local filesystem.
40
+ */
41
+ export declare function queryBundle(outputDir: string, cms: CMSProvider, contentType: string, options?: QueryOptions): Promise<unknown[]>;
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Trims nested object depth.
5
+ *
6
+ * `remaining` represents how many levels the current object is allowed.
7
+ * - Scalar properties are always kept.
8
+ * - Nested objects / arrays-of-objects consume one level. When `remaining`
9
+ * drops to 1 they are replaced with `null` (no budget left for refs).
10
+ */
11
+ export function trimDepth(value, remaining) {
12
+ if (value === null || value === undefined || typeof value !== 'object') {
13
+ return value;
14
+ }
15
+ if (Array.isArray(value)) {
16
+ return value.map((item) => trimDepth(item, remaining));
17
+ }
18
+ const obj = value;
19
+ const result = {};
20
+ for (const [k, v] of Object.entries(obj)) {
21
+ if (v === null || v === undefined || typeof v !== 'object') {
22
+ result[k] = v;
23
+ }
24
+ else if (remaining <= 1) {
25
+ result[k] = null;
26
+ }
27
+ else if (Array.isArray(v)) {
28
+ result[k] = v.map((item) => {
29
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
30
+ return trimDepth(item, remaining - 1);
31
+ }
32
+ return item;
33
+ });
34
+ }
35
+ else {
36
+ result[k] = trimDepth(v, remaining - 1);
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+ /**
42
+ * Downloads the latest bundles from S3 and writes them as JSON files to `outputDir`.
43
+ *
44
+ * @returns A map of contentType to absolute file path.
45
+ */
46
+ export async function fetchBundles(store, outputDir, options) {
47
+ const { cms, contentTypes } = options;
48
+ await fs.mkdir(outputDir, { recursive: true });
49
+ const result = {};
50
+ await Promise.all(contentTypes.map(async (contentType) => {
51
+ const key = store.buildLatestKey(cms, contentType);
52
+ const data = await store.download(key);
53
+ const filePath = path.resolve(outputDir, `content-${cms}-${contentType}.json`);
54
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
55
+ result[contentType] = filePath;
56
+ }));
57
+ return result;
58
+ }
59
+ /**
60
+ * Queries a previously fetched bundle from the local filesystem.
61
+ */
62
+ export async function queryBundle(outputDir, cms, contentType, options = {}) {
63
+ const filePath = path.resolve(outputDir, `content-${cms}-${contentType}.json`);
64
+ const raw = await fs.readFile(filePath, 'utf-8');
65
+ let items = JSON.parse(raw);
66
+ if (options.fields) {
67
+ const filters = options.fields;
68
+ items = items.filter((item) => Object.entries(filters).every(([key, expected]) => {
69
+ const actual = item[key];
70
+ if (Array.isArray(expected))
71
+ return expected.includes(actual);
72
+ return actual === expected;
73
+ }));
74
+ }
75
+ if (options.limit !== undefined && options.limit > 0) {
76
+ items = items.slice(0, options.limit);
77
+ }
78
+ if (options.include !== undefined) {
79
+ items = items.map((item) => trimDepth(item, options.include));
80
+ }
81
+ if (options.select?.length) {
82
+ const keys = options.select;
83
+ items = items.map((item) => {
84
+ const picked = {};
85
+ for (const k of keys) {
86
+ if (k in item)
87
+ picked[k] = item[k];
88
+ }
89
+ return picked;
90
+ });
91
+ }
92
+ return items;
93
+ }
94
+ //# sourceMappingURL=bundles.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundles.js","sourceRoot":"","sources":["../../src/shared/bundles.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import type { S3Config } from './types.js';
2
+ export declare class ContentStore {
3
+ private client;
4
+ private bucket;
5
+ constructor(cfg: S3Config);
6
+ /** content-{cms}-{contentType}-{timestamp}.json */
7
+ buildVersionedKey(cms: string, contentType: string, timestamp: number): string;
8
+ /** content-{cms}-{contentType}.json (always points at the latest version) */
9
+ buildLatestKey(cms: string, contentType: string): string;
10
+ upload(key: string, data: unknown): Promise<string>;
11
+ download(key: string): Promise<unknown>;
12
+ /**
13
+ * Copies a versioned object to the "latest" key so that it always reflects
14
+ * the most recent sync while older timestamped versions are retained.
15
+ */
16
+ copyToLatest(sourceKey: string, cms: string, contentType: string): Promise<string>;
17
+ }
@@ -0,0 +1,54 @@
1
+ import { S3Client, PutObjectCommand, CopyObjectCommand, GetObjectCommand, } from '@aws-sdk/client-s3';
2
+ export class ContentStore {
3
+ client;
4
+ bucket;
5
+ constructor(cfg) {
6
+ this.client = new S3Client({
7
+ region: cfg.region,
8
+ credentials: {
9
+ accessKeyId: cfg.accessKeyId,
10
+ secretAccessKey: cfg.secretAccessKey,
11
+ },
12
+ });
13
+ this.bucket = cfg.bucket;
14
+ }
15
+ /** content-{cms}-{contentType}-{timestamp}.json */
16
+ buildVersionedKey(cms, contentType, timestamp) {
17
+ return `content-${cms}-${contentType}-${timestamp}.json`;
18
+ }
19
+ /** content-{cms}-{contentType}.json (always points at the latest version) */
20
+ buildLatestKey(cms, contentType) {
21
+ return `content-${cms}-${contentType}.json`;
22
+ }
23
+ async upload(key, data) {
24
+ await this.client.send(new PutObjectCommand({
25
+ Bucket: this.bucket,
26
+ Key: key,
27
+ Body: JSON.stringify(data, null, 2),
28
+ ContentType: 'application/json',
29
+ }));
30
+ return key;
31
+ }
32
+ async download(key) {
33
+ const response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
34
+ const body = await response.Body?.transformToString();
35
+ if (!body)
36
+ throw new Error(`Empty response for key: ${key}`);
37
+ return JSON.parse(body);
38
+ }
39
+ /**
40
+ * Copies a versioned object to the "latest" key so that it always reflects
41
+ * the most recent sync while older timestamped versions are retained.
42
+ */
43
+ async copyToLatest(sourceKey, cms, contentType) {
44
+ const latestKey = this.buildLatestKey(cms, contentType);
45
+ await this.client.send(new CopyObjectCommand({
46
+ Bucket: this.bucket,
47
+ CopySource: `${this.bucket}/${sourceKey}`,
48
+ Key: latestKey,
49
+ ContentType: 'application/json',
50
+ }));
51
+ return latestKey;
52
+ }
53
+ }
54
+ //# sourceMappingURL=s3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"s3.js","sourceRoot":"","sources":["../../src/shared/s3.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,QAAQ,EACR,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,oBAAoB,CAAC;AAG5B,MAAM,OAAO,YAAY;IACf,MAAM,CAAW;IACjB,MAAM,CAAS;IAEvB,YAAY,GAAa;QACvB,IAAI,CAAC,MAAM,GAAG,IAAI,QAAQ,CAAC;YACzB,MAAM,EAAE,GAAG,CAAC,MAAM;YAClB,WAAW,EAAE;gBACX,WAAW,EAAE,GAAG,CAAC,WAAW;gBAC5B,eAAe,EAAE,GAAG,CAAC,eAAe;aACrC;SACF,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC;IAC3B,CAAC;IAED,mDAAmD;IACnD,iBAAiB,CAAC,GAAW,EAAE,WAAmB,EAAE,SAAiB;QACnE,OAAO,WAAW,GAAG,IAAI,WAAW,IAAI,SAAS,OAAO,CAAC;IAC3D,CAAC;IAED,8EAA8E;IAC9E,cAAc,CAAC,GAAW,EAAE,WAAmB;QAC7C,OAAO,WAAW,GAAG,IAAI,WAAW,OAAO,CAAC;IAC9C,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW,EAAE,IAAa;QACrC,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CACpB,IAAI,gBAAgB,CAAC;YACnB,MAAM,EAAE,IAAI,CAAC,MAAM;YACnB,GAAG,EAAE,GAAG;YACR,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YACnC,WAAW,EAAE,kBAAkB;SAChC,CAAC,CACH,CAAC;QACF,OAAO,GAAG,CAAC;IACb,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,GAAW;QACxB,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CACrC,IAAI,gBAAgB,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CACxD,CAAC;QACF,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,iBAAiB,EAAE,CAAC;QACtD,IAAI,CAAC,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,GAAG,EAAE,CAAC,CAAC;QAC7D,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,YAAY,CAChB,SAAiB,EACjB,GAAW,EACX,WAAmB;QAEnB,MAAM,SAAS,GAAG,IAAI,CAAC,cAAc,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC;QACxD,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CACpB,IAAI,iBAAiB,CAAC;YACpB,MAAM,EAAE,IAAI,CAAC,MAAM;YACnB,UAAU,EAAE,GAAG,IAAI,CAAC,MAAM,IAAI,SAAS,EAAE;YACzC,GAAG,EAAE,SAAS;YACd,WAAW,EAAE,kBAAkB;SAChC,CAAC,CACH,CAAC;QACF,OAAO,SAAS,CAAC;IACnB,CAAC;CACF"}
@@ -0,0 +1,7 @@
1
+ export type CMSProvider = 'contentful' | 'sanity';
2
+ export interface S3Config {
3
+ bucket: string;
4
+ region: string;
5
+ accessKeyId: string;
6
+ secretAccessKey: string;
7
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/shared/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@tandem-language-exchange/content-store",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist/index.*",
15
+ "dist/sdk/",
16
+ "dist/shared/"
17
+ ],
18
+ "engines": {
19
+ "npm": "^11.3.0",
20
+ "node": "^24.1.0"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "dev": "tsx watch src/server/cli.ts serve",
25
+ "start": "node dist/server/cli.js serve",
26
+ "sync": "tsx src/server/cli.ts sync",
27
+ "sync-appconfig:staging": "sync-appconfig env=staging project=content-store",
28
+ "sync-appconfig:production": "sync-appconfig env=production project=content-store",
29
+ "sync-appconfig:local": "sync-appconfig env=local project=content-store",
30
+ "sync:contentful": "tsx src/server/cli.ts sync --cms contentful",
31
+ "sync:sanity": "tsx src/server/cli.ts sync --cms sanity",
32
+ "fetch": "tsx src/server/cli.ts fetch",
33
+ "query": "tsx src/server/cli.ts query",
34
+ "lint": "eslint .",
35
+ "lint:fix": "eslint --fix ."
36
+ },
37
+ "dependencies": {
38
+ "@aws-sdk/client-s3": "^3.700.0",
39
+ "@sanity/client": "^7.20.0",
40
+ "commander": "^13.1.0",
41
+ "contentful": "11.10.3",
42
+ "dotenv": "16.4.5",
43
+ "express": "^5.1.0",
44
+ "nodemon": "^3.1.14"
45
+ },
46
+ "devDependencies": {
47
+ "@tandem-web/web-azure-appconfig-sync": "1.0.9",
48
+ "@types/express": "^5.0.2",
49
+ "@types/node": "22.0.0",
50
+ "tsx": "^4.19.0",
51
+ "typescript": "^5.9.3"
52
+ }
53
+ }