@pplethai/cli 0.7.0 → 0.8.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.
Files changed (51) hide show
  1. package/dist/bin.js +171 -22
  2. package/package.json +3 -2
  3. package/templates/golden-path/.dev.vars.example +19 -1
  4. package/templates/golden-path/CLAUDE.md +51 -13
  5. package/templates/golden-path/README.md +27 -2
  6. package/templates/golden-path/gitignore +3 -0
  7. package/templates/golden-path/overlay-mtime.mjs +30 -0
  8. package/templates/golden-path/package-lock.json +19 -1
  9. package/templates/golden-path/package.json +3 -2
  10. package/templates/golden-path/scripts/assert-dev-compiled-out.mjs +42 -0
  11. package/templates/golden-path/shared/api.ts +25 -2
  12. package/templates/golden-path/src/auth.ts +72 -7
  13. package/templates/golden-path/src/call-service.ts +140 -0
  14. package/templates/golden-path/src/dataset-query.ts +470 -0
  15. package/templates/golden-path/src/dataset-schema.ts +398 -0
  16. package/templates/golden-path/src/dev-call-service.ts +391 -0
  17. package/templates/golden-path/src/dev-personas.ts +49 -15
  18. package/templates/golden-path/src/dev-read-dataset.ts +429 -0
  19. package/templates/golden-path/src/dev-registry.ts +276 -0
  20. package/templates/golden-path/src/env.ts +13 -2
  21. package/templates/golden-path/src/identity-header.ts +17 -0
  22. package/templates/golden-path/src/index.ts +32 -1
  23. package/templates/golden-path/src/legal-identity.ts +47 -28
  24. package/templates/golden-path/src/levels.ts +132 -0
  25. package/templates/golden-path/src/read-dataset.ts +376 -0
  26. package/templates/golden-path/src/registry.ts +122 -0
  27. package/templates/golden-path/src/vite-env.d.ts +13 -0
  28. package/templates/golden-path/test/call-service.test.ts +178 -0
  29. package/templates/golden-path/test/dev-call-service.test.ts +279 -0
  30. package/templates/golden-path/test/dev-read-dataset.test.ts +270 -0
  31. package/templates/golden-path/test/dev-registry.test.ts +375 -0
  32. package/templates/golden-path/test/effective-sub.test.ts +101 -0
  33. package/templates/golden-path/test/fixtures/overlay/directory/branches/entry.md +85 -0
  34. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/audience.json +3 -0
  35. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/named.json +3 -0
  36. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/offices.json +6 -0
  37. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/roster.json +3 -0
  38. package/templates/golden-path/test/fixtures/overlay/directory/branches/mocks/summary.json +1 -0
  39. package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/entry.md +8 -0
  40. package/templates/golden-path/test/fixtures/overlay/platform/legal-identity/mocks/responses.json +13 -0
  41. package/templates/golden-path/test/fixtures/overlay/shop/catalog/entry.md +8 -0
  42. package/templates/golden-path/test/fixtures/overlay/shop/catalog/mocks/responses.json +7 -0
  43. package/templates/golden-path/test/fixtures/overlay/shop/orders/entry.md +12 -0
  44. package/templates/golden-path/test/fixtures/overlay/shop/orders/mocks/responses.json +36 -0
  45. package/templates/golden-path/test/harness.ts +101 -16
  46. package/templates/golden-path/test/legal-identity.test.ts +91 -24
  47. package/templates/golden-path/test/membership.test.ts +105 -0
  48. package/templates/golden-path/test/read-dataset.test.ts +214 -0
  49. package/templates/golden-path/test/service-call.test.ts +166 -0
  50. package/templates/golden-path/vite.config.ts +2 -1
  51. package/templates/golden-path/vitest.config.ts +2 -0
@@ -0,0 +1,429 @@
1
+ // Offline Dataset reads (ADR-0030, ADR-0026). Active only where
2
+ // `import.meta.env.DEV` is true and `DEV_PERSONA` is not `"none"` — `vite dev`,
3
+ // not vitest and not the production build. `readDataset` reaches this module
4
+ // only from that branch, so `vite build` drops it the same way it drops the
5
+ // Persona bypass (ADR-0011).
6
+ //
7
+ // The overlay is `.pple/registry/<provider>/<name>/`. Vite's `import.meta.glob`
8
+ // skips a dot-folder unless the dot sits in the pattern's fixed base, so both
9
+ // globs below keep `.pple/registry` in that base. CI has no overlay; tests
10
+ // pass a fixture glob to `answerOfflineDataset` and never read `.pple/`.
11
+ //
12
+ // Object Type shape, schema, id, index and level come from `entry.md`'s last
13
+ // approved frontmatter. Static `subs` arrays are not in that file (ADR-0025),
14
+ // and a swept list never reaches the workspace. A missing list is empty, so
15
+ // `passesLevel` admits nobody for that part — the same as before the first
16
+ // sweep (ADR-0028) and the same as a level made only of subs (ADR-0026).
17
+ // That fails closed: the offline path does not invent a list it cannot see.
18
+ //
19
+ // `sweptAt` is the mock file's write time, or `entry.md`'s if the mock has
20
+ // none. The Worker cannot stat, so the dev server embeds the mtime. `snapshot`
21
+ // is a stable hash of the mock text: the warehouse's integer is not in the
22
+ // overlay, and pages of one file must share a snapshot. A declared Object Type
23
+ // with no mock file is `no_mock`, not an empty page. Offline has no warehouse,
24
+ // and a missing mock is the dev-only miss (ADR-0026).
25
+
26
+ import { parse as parseYaml } from "yaml";
27
+ import { forcedError, platformRefusal } from "./dev-call-service";
28
+ import { resolveDevPersona } from "./dev-personas";
29
+ import { applyDatasetQuery, datasetOpaqueHash, datasetPageHash, parseDatasetQuery, type DatasetQuerySpec, type DatasetRow } from "./dataset-query";
30
+ import { passesLevel, type AccessLevel } from "./levels";
31
+ import { isPlainObject } from "./dataset-schema";
32
+
33
+ const MOCK_HEADER = "x-pple-mock";
34
+ const ERROR_HEADER = "x-pple-error";
35
+
36
+ export interface OfflineDatasetCall {
37
+ entry: string;
38
+ objectType: string;
39
+ id?: string;
40
+ /** Query entries, in the order `readDataset` puts them on the URL. */
41
+ search?: readonly (readonly [string, string])[];
42
+ userToken?: string;
43
+ /** Active Persona name. An unknown name warns and uses `hq-staff`. */
44
+ persona: string;
45
+ devRegistryError?: string;
46
+ }
47
+
48
+ interface DeclaredType {
49
+ shape: "singleton" | "collection";
50
+ level: AccessLevel;
51
+ idProperty: string;
52
+ spec: DatasetQuerySpec | null;
53
+ }
54
+
55
+ interface IndexedEntry {
56
+ types: Map<string, DeclaredType> | null;
57
+ mocks: Map<string, string>;
58
+ }
59
+
60
+ /** `npm run dev` entry. Reads the overlay glob, its mtimes, and the active Persona. */
61
+ export function answerOfflineRead(
62
+ env: { DEV_PERSONA?: string; DEV_REGISTRY_ERROR?: string },
63
+ entry: string,
64
+ objectType: string,
65
+ request: { id?: string; search?: readonly (readonly [string, string])[]; userToken?: string },
66
+ ): Response {
67
+ return answerOfflineDataset(overlayFiles(), overlayWrittenAt(), {
68
+ entry,
69
+ objectType,
70
+ id: request.id,
71
+ search: request.search,
72
+ userToken: request.userToken,
73
+ persona: env.DEV_PERSONA ?? "",
74
+ devRegistryError: env.DEV_REGISTRY_ERROR,
75
+ });
76
+ }
77
+
78
+ /**
79
+ * The response a Builder observes for one offline read. `files` is an
80
+ * `import.meta.glob` of raw text. `writtenAt` is the matching `?mtime` glob:
81
+ * each value is the file's write time.
82
+ */
83
+ export function answerOfflineDataset(
84
+ files: Readonly<Record<string, unknown>>,
85
+ writtenAt: Readonly<Record<string, unknown>>,
86
+ call: OfflineDatasetCall,
87
+ ): Response {
88
+ const forced = forcedError(call.devRegistryError, call.entry);
89
+ if (forced) return platformRefusal(forced);
90
+
91
+ const indexed = indexOverlay(files);
92
+ const entry = indexed.get(call.entry);
93
+ if (!entry) return noMock(missingEntryMessage(call.entry));
94
+
95
+ const declared = entry.types?.get(call.objectType) ?? null;
96
+ if (!declared) return platformRefusal("not_found");
97
+
98
+ const identity = resolveDevPersona(call.persona);
99
+ if (declared.level !== "public" && !hasToken(call.userToken)) {
100
+ return platformRefusal("user_token_invalid");
101
+ }
102
+ // Once, for the Object Type, before any row is read. A swept list is empty
103
+ // here: it is not in the overlay. A static list was removed from entry.md,
104
+ // so a missing `subs` array admits nobody.
105
+ const swept = typeof declared.level === "object" && declared.level.subs === "swept";
106
+ if (!passesLevel(identity, declared.level, swept ? { sweptSubs: [] } : undefined)) {
107
+ return platformRefusal("level_not_met");
108
+ }
109
+
110
+ const mock = entry.mocks.get(call.objectType);
111
+ if (mock === undefined) return noMock(missingMockMessage(call.entry, call.objectType));
112
+
113
+ const sweptAt = writtenTime(writtenAt, call.entry, call.objectType);
114
+ const snapshot = snapshotOf(mock);
115
+ const search = call.search ?? [];
116
+
117
+ if (declared.shape === "singleton") {
118
+ if (call.id !== undefined) return platformRefusal("not_found");
119
+ if (search.length > 0) return platformRefusal("invalid_query");
120
+ const object = parseSingleton(call.entry, call.objectType, mock);
121
+ if (object === undefined) return noMock(unreadableMockMessage(call.entry, call.objectType));
122
+ return jsonAnswer(call.entry, { object, sweptAt, snapshot }, etag(snapshot, datasetOpaqueHash({ singleton: true })));
123
+ }
124
+
125
+ // A query on an id lookup is invalid even when the declaration cannot be
126
+ // queried. A list against that declaration is invalid_query too.
127
+ if (call.id !== undefined && search.length > 0) return platformRefusal("invalid_query");
128
+ if (!declared.spec) {
129
+ return call.id !== undefined ? platformRefusal("not_found") : platformRefusal("invalid_query");
130
+ }
131
+
132
+ const rows = parseRows(call.entry, call.objectType, mock, declared);
133
+ if (!rows) return noMock(unreadableMockMessage(call.entry, call.objectType));
134
+
135
+ if (call.id !== undefined) {
136
+ const id = canonicalId(call.id, declared.spec.idType);
137
+ const found = id === null ? undefined : rows.find((row) => row.id === id);
138
+ if (!found) return platformRefusal("not_found");
139
+ return jsonAnswer(
140
+ call.entry,
141
+ { object: found.object, sweptAt, snapshot },
142
+ etag(snapshot, datasetOpaqueHash({ lookup: id })),
143
+ );
144
+ }
145
+
146
+ const params = new URLSearchParams();
147
+ for (const [key, value] of search) params.append(key, value);
148
+ const parsed = parseDatasetQuery(params, declared.spec);
149
+ if (!parsed.ok) return platformRefusal("invalid_query");
150
+ const page = applyDatasetQuery(rows, parsed.query, declared.spec);
151
+ return jsonAnswer(
152
+ call.entry,
153
+ { objects: page.objects, next: page.next, sweptAt, snapshot },
154
+ etag(snapshot, datasetPageHash(parsed.query)),
155
+ );
156
+ }
157
+
158
+ function overlayFiles(): Record<string, unknown> {
159
+ if (!import.meta.env.DEV) return {};
160
+ return import.meta.glob("../.pple/registry/**/*", {
161
+ eager: true,
162
+ query: "?raw",
163
+ import: "default",
164
+ });
165
+ }
166
+
167
+ function overlayWrittenAt(): Record<string, unknown> {
168
+ if (!import.meta.env.DEV) return {};
169
+ return import.meta.glob("../.pple/registry/**/*", {
170
+ eager: true,
171
+ query: "?mtime",
172
+ import: "default",
173
+ });
174
+ }
175
+
176
+ function indexOverlay(files: Readonly<Record<string, unknown>>): Map<string, IndexedEntry> {
177
+ const found = new Map<string, { entryMd?: string; mocks: Map<string, string> }>();
178
+ const touch = (id: string) => {
179
+ const row = found.get(id) ?? { mocks: new Map<string, string>() };
180
+ found.set(id, row);
181
+ return row;
182
+ };
183
+ for (const [file, value] of Object.entries(files)) {
184
+ const text = asText(value);
185
+ if (text === null) continue;
186
+ const path = normalize(file);
187
+ const entryMd = path.match(/(?:^|\/)([^/]+)\/([^/]+)\/entry\.md$/);
188
+ if (entryMd?.[1] && entryMd[2]) {
189
+ touch(`${entryMd[1]}/${entryMd[2]}`).entryMd = text;
190
+ continue;
191
+ }
192
+ const mock = path.match(/(?:^|\/)([^/]+)\/([^/]+)\/mocks\/([^/]+)\.json$/);
193
+ if (mock?.[1] && mock[2] && mock[3]) {
194
+ touch(`${mock[1]}/${mock[2]}`).mocks.set(mock[3], text);
195
+ }
196
+ }
197
+ const entries = new Map<string, IndexedEntry>();
198
+ for (const [id, row] of found) {
199
+ entries.set(id, {
200
+ types: row.entryMd === undefined ? null : declaredTypes(id, row.entryMd),
201
+ mocks: row.mocks,
202
+ });
203
+ }
204
+ return entries;
205
+ }
206
+
207
+ function declaredTypes(entry: string, entryMd: string): Map<string, DeclaredType> | null {
208
+ const parsed = readFrontmatter(entryMd);
209
+ if (!parsed) {
210
+ console.warn(`readDataset could not read .pple/registry/${entry}/entry.md. Run pple registry to refresh it.`);
211
+ return null;
212
+ }
213
+ const datasetLevel = "level" in parsed ? asLevel(parsed.level) : null;
214
+ if ("level" in parsed && datasetLevel === null) return null;
215
+ const raw = parsed.objectTypes;
216
+ if (!isPlainObject(raw)) return new Map();
217
+ const types = new Map<string, DeclaredType>();
218
+ for (const [name, body] of Object.entries(raw)) {
219
+ if (!isPlainObject(body)) continue;
220
+ const shape = body.shape === "singleton" || body.shape === "collection" ? body.shape : null;
221
+ if (!shape) continue;
222
+ const ownLevel = "level" in body ? asLevel(body.level) : null;
223
+ if ("level" in body && ownLevel === null) continue;
224
+ const level = ownLevel ?? datasetLevel;
225
+ if (!level) continue;
226
+ const schema = isPlainObject(body.schema) ? body.schema : null;
227
+ if (!schema) continue;
228
+ if (shape === "singleton") {
229
+ types.set(name, { shape, level, idProperty: "id", spec: null });
230
+ continue;
231
+ }
232
+ const idProperty = typeof body.id === "string" && body.id !== "" ? body.id : "id";
233
+ const index = readIndex(body.index, schema);
234
+ const idType = idTypeOf(schema, idProperty);
235
+ types.set(name, {
236
+ shape,
237
+ level,
238
+ idProperty,
239
+ spec: index === null ? null : { idType, index },
240
+ });
241
+ }
242
+ return types;
243
+ }
244
+
245
+ function readFrontmatter(entryMd: string): Record<string, unknown> | null {
246
+ const fence = entryMd.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
247
+ const body = fence?.[1];
248
+ if (body === undefined) return null;
249
+ try {
250
+ const value = parseYaml(body) as unknown;
251
+ if (!isPlainObject(value)) return null;
252
+ return value;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ function asLevel(value: unknown): AccessLevel | null {
259
+ if (value === "public" || value === "member" || value === "internal") return value;
260
+ if (!isPlainObject(value)) return null;
261
+ const level: { roles?: string[]; subs?: string[] | "swept" } = {};
262
+ if ("roles" in value) {
263
+ if (!Array.isArray(value.roles) || value.roles.some((item) => typeof item !== "string")) return null;
264
+ level.roles = value.roles;
265
+ }
266
+ if ("subs" in value) {
267
+ if (value.subs === "swept") level.subs = "swept";
268
+ else if (Array.isArray(value.subs) && value.subs.every((item) => typeof item === "string")) {
269
+ level.subs = value.subs;
270
+ } else return null;
271
+ }
272
+ return level;
273
+ }
274
+
275
+ function readIndex(
276
+ value: unknown,
277
+ schema: Record<string, unknown>,
278
+ ): { name: string; schema: Record<string, unknown> }[] | null {
279
+ if (value === undefined) return [];
280
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item === "")) return null;
281
+ const properties = isPlainObject(schema.properties) ? schema.properties : null;
282
+ if (!properties) return null;
283
+ const index: { name: string; schema: Record<string, unknown> }[] = [];
284
+ for (const name of value) {
285
+ const property = properties[name];
286
+ if (!isPlainObject(property)) return null;
287
+ index.push({ name, schema: property });
288
+ }
289
+ return index;
290
+ }
291
+
292
+ function idTypeOf(schema: Record<string, unknown>, idProperty: string): DatasetQuerySpec["idType"] {
293
+ const properties = isPlainObject(schema.properties) ? schema.properties : null;
294
+ const idSchema = properties ? properties[idProperty] : null;
295
+ return isPlainObject(idSchema) && idSchema.type === "integer" ? "integer" : "string";
296
+ }
297
+
298
+ function parseSingleton(entry: string, objectType: string, text: string): Record<string, unknown> | null | undefined {
299
+ const parsed = parseJson(entry, objectType, text);
300
+ if (parsed === undefined) return undefined;
301
+ if (parsed === null) return null;
302
+ if (!isPlainObject(parsed)) return undefined;
303
+ return parsed;
304
+ }
305
+
306
+ function parseRows(entry: string, objectType: string, text: string, declared: DeclaredType): DatasetRow[] | null {
307
+ const parsed = parseJson(entry, objectType, text);
308
+ if (!Array.isArray(parsed)) return null;
309
+ const idType = declared.spec?.idType ?? "string";
310
+ const rows: DatasetRow[] = [];
311
+ for (const item of parsed) {
312
+ if (!isPlainObject(item)) return null;
313
+ const id = rowId(item, declared.idProperty, idType);
314
+ if (id === null) return null;
315
+ rows.push({ id, object: item });
316
+ }
317
+ return rows;
318
+ }
319
+
320
+ function parseJson(entry: string, objectType: string, text: string): unknown | undefined {
321
+ try {
322
+ return JSON.parse(text) as unknown;
323
+ } catch {
324
+ console.warn(
325
+ `readDataset could not read .pple/registry/${entry}/mocks/${objectType}.json. Run pple registry to refresh it.`,
326
+ );
327
+ return undefined;
328
+ }
329
+ }
330
+
331
+ function rowId(object: Record<string, unknown>, idProperty: string, idType: DatasetQuerySpec["idType"]): string | null {
332
+ if (!Object.hasOwn(object, idProperty)) return null;
333
+ const value = object[idProperty];
334
+ if (idType === "integer") {
335
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) return null;
336
+ return String(value);
337
+ }
338
+ if (typeof value !== "string" || value === "") return null;
339
+ return value;
340
+ }
341
+
342
+ function canonicalId(raw: string, idType: DatasetQuerySpec["idType"]): string | null {
343
+ if (idType === "string") return raw === "" ? null : raw;
344
+ if (!/^-?\d+$/.test(raw)) return null;
345
+ const value = Number(raw);
346
+ if (!Number.isSafeInteger(value)) return null;
347
+ return String(value);
348
+ }
349
+
350
+ function writtenTime(writtenAt: Readonly<Record<string, unknown>>, entry: string, objectType: string): string | null {
351
+ const times = new Map<string, string>();
352
+ for (const [file, value] of Object.entries(writtenAt)) {
353
+ if (typeof value !== "string" || value === "") continue;
354
+ times.set(normalize(file), value);
355
+ }
356
+ const mockSuffix = `${entry}/mocks/${objectType}.json`;
357
+ const entrySuffix = `${entry}/entry.md`;
358
+ for (const [path, iso] of times) {
359
+ if (pathEnds(path, mockSuffix)) return iso;
360
+ }
361
+ for (const [path, iso] of times) {
362
+ if (pathEnds(path, entrySuffix)) return iso;
363
+ }
364
+ return null;
365
+ }
366
+
367
+ function pathEnds(path: string, suffix: string): boolean {
368
+ return path === suffix || path.endsWith(`/${suffix}`);
369
+ }
370
+
371
+ /** Stable across pages of one file, and different after the mock text changes. */
372
+ function snapshotOf(text: string): number {
373
+ let hash = 0x811c9dc5;
374
+ for (let i = 0; i < text.length; i++) {
375
+ hash ^= text.charCodeAt(i);
376
+ hash = Math.imul(hash, 0x01000193);
377
+ }
378
+ const value = hash >>> 0;
379
+ return value === 0 ? 1 : value;
380
+ }
381
+
382
+ function etag(snapshot: number, hash: string): string {
383
+ return `"${snapshot}-${hash}"`;
384
+ }
385
+
386
+ function hasToken(userToken: string | undefined): boolean {
387
+ return userToken !== undefined && userToken.trim() !== "";
388
+ }
389
+
390
+ function noMock(message: string): Response {
391
+ return Response.json(
392
+ { error: "no_mock", message },
393
+ { status: 404, headers: { [ERROR_HEADER]: "no_mock" } },
394
+ );
395
+ }
396
+
397
+ function missingEntryMessage(entry: string): string {
398
+ return `ไม่มี ${entry} ใน .pple/registry/ — รัน pple registry เพื่อดึงข้อมูลจำลอง`;
399
+ }
400
+
401
+ function missingMockMessage(entry: string, objectType: string): string {
402
+ return `${entry} ไม่มีข้อมูลจำลองสำหรับ ${objectType} — รัน pple registry เพื่อรีเฟรช`;
403
+ }
404
+
405
+ function unreadableMockMessage(entry: string, objectType: string): string {
406
+ return `อ่าน .pple/registry/${entry}/mocks/${objectType}.json ไม่ได้ — รัน pple registry เพื่อรีเฟรช`;
407
+ }
408
+
409
+ function jsonAnswer(entry: string, body: unknown, tag: string): Response {
410
+ const headers = new Headers({
411
+ "content-type": "application/json; charset=utf-8",
412
+ etag: tag,
413
+ [MOCK_HEADER]: entry,
414
+ });
415
+ return new Response(JSON.stringify(body), { status: 200, headers });
416
+ }
417
+
418
+ function asText(value: unknown): string | null {
419
+ if (typeof value === "string") return value;
420
+ if (value !== null && typeof value === "object" && "default" in value) {
421
+ const inner = (value as { default: unknown }).default;
422
+ if (typeof inner === "string") return inner;
423
+ }
424
+ return null;
425
+ }
426
+
427
+ function normalize(file: string): string {
428
+ return file.split("?")[0]?.replaceAll("\\", "/") ?? file;
429
+ }