@geniee-inc/sfa-kit 1.14.1 → 1.17.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 (43) hide show
  1. package/GUIDE.md +131 -12
  2. package/package.json +19 -2
  3. package/src/bin/sfa-kit-bundle.d.ts +13 -0
  4. package/src/bin/sfa-kit-bundle.js +142 -0
  5. package/src/bundle.d.ts +26 -0
  6. package/src/bundle.js +1 -0
  7. package/src/lib/auth/ObjectKeyGuard.d.ts +12 -0
  8. package/src/lib/auth/ObjectKeyGuard.js +61 -0
  9. package/src/lib/auth/SfaLoginGate.js +5 -4
  10. package/src/lib/bundle/build-static-bundle.d.ts +45 -0
  11. package/src/lib/bundle/build-static-bundle.js +263 -0
  12. package/src/lib/bundle/esbuild-loader.d.ts +25 -0
  13. package/src/lib/bundle/esbuild-loader.js +27 -0
  14. package/src/lib/bundle/index.d.ts +2 -0
  15. package/src/lib/bundle/index.js +1 -0
  16. package/src/lib/bundle/request-signature.d.ts +22 -0
  17. package/src/lib/bundle/request-signature.js +81 -0
  18. package/src/lib/hooks/splitBulkRows.d.ts +8 -4
  19. package/src/lib/hooks/splitBulkRows.js +17 -4
  20. package/src/lib/hooks/useBulkCreateRecords.d.ts +8 -3
  21. package/src/lib/hooks/useBulkCreateRecords.js +5 -2
  22. package/src/lib/hooks/useBulkDeleteRecords.d.ts +14 -1
  23. package/src/lib/hooks/useBulkDeleteRecords.js +4 -0
  24. package/src/lib/hooks/useBulkUpdateRecords.d.ts +8 -2
  25. package/src/lib/hooks/useBulkUpdateRecords.js +3 -1
  26. package/src/lib/provider/SfaClient.d.ts +1 -0
  27. package/src/lib/provider/SfaClient.js +18 -5
  28. package/src/lib/provider/types.d.ts +19 -3
  29. package/src/lib/signing/compute-content-hash.d.ts +1 -1
  30. package/src/lib/signing/compute-signature.d.ts +17 -0
  31. package/src/lib/signing/compute-signature.js +17 -0
  32. package/src/lib/signing/create-manifest-from-directory.d.ts +13 -3
  33. package/src/lib/signing/create-manifest-from-directory.js +15 -28
  34. package/src/lib/signing/create-manifest.d.ts +7 -1
  35. package/src/lib/signing/create-manifest.js +8 -2
  36. package/src/lib/signing/index.d.ts +6 -5
  37. package/src/lib/signing/index.js +5 -4
  38. package/src/lib/signing/read-bundle-files.d.ts +19 -0
  39. package/src/lib/signing/read-bundle-files.js +41 -0
  40. package/src/lib/version.d.ts +1 -1
  41. package/src/lib/version.js +1 -1
  42. package/src/signing.d.ts +9 -4
  43. package/src/signing.js +1 -1
@@ -0,0 +1,263 @@
1
+ import { __awaiter } from "tslib";
2
+ import { mkdir, readFile, readdir, rm, stat, writeFile, } from 'node:fs/promises';
3
+ import { join, posix, relative } from 'node:path';
4
+ import { computeContentHash } from '../signing/compute-content-hash.js';
5
+ import { readBundleFiles } from '../signing/read-bundle-files.js';
6
+ import { loadEsbuild } from './esbuild-loader.js';
7
+ import { requestSignature } from './request-signature.js';
8
+ /** Where the bundled script lands inside the output directory. */
9
+ const BUNDLE_PATH = 'assets/bundle.js';
10
+ /** esbuild writes this beside the script when the entry imports any CSS. */
11
+ const STYLES_PATH = 'assets/bundle.css';
12
+ /**
13
+ * Every path this builder writes, relative to `outDir` with forward slashes.
14
+ *
15
+ * NOTE: full paths, not top-level names. Matching on names would accept any
16
+ * directory shaped like a static site — an `index.html` beside an `assets/`
17
+ * folder — and clear it.
18
+ */
19
+ const OWN_OUTPUT = new Set([
20
+ 'index.html',
21
+ 'manifest.json',
22
+ BUNDLE_PATH,
23
+ STYLES_PATH,
24
+ ]);
25
+ /** How many unexpected paths an error names before it stops listing them. */
26
+ const UNEXPECTED_SHOWN = 5;
27
+ /**
28
+ * Removed rather than refused. Opening the output directory in Finder to zip
29
+ * it — which GUIDE.md tells the reader to do — leaves a `.DS_Store` behind,
30
+ * and refusing it would wedge the build → zip → rebuild loop on a hidden file.
31
+ */
32
+ const JUNK_FILES = new Set(['.DS_Store', 'Thumbs.db']);
33
+ /**
34
+ * @description Builds the framework-free static bundle a page is uploaded as
35
+ * to become a custom tab, and signs it.
36
+ *
37
+ * Produces `index.html`, `assets/bundle.js` and a signed `manifest.json` in
38
+ * `outDir`. The caller zips that directory's contents — the files have to sit
39
+ * at the zip root. Zipping is left out because Node ships no zip writer, and a
40
+ * dependency for it would reach a package whose runtime needs only `tslib`.
41
+ *
42
+ * Signing is not optional, and a failure removes the output: an unsigned bundle
43
+ * uploads successfully while Keystone's verifier runs in `disabled` mode and
44
+ * only starts failing at `enforced`, so a half-built directory left behind is
45
+ * something a caller can still zip and upload by mistake.
46
+ *
47
+ * **Node.js only**, and needs `esbuild` installed alongside the kit.
48
+ *
49
+ * @returns The manifest that was written.
50
+ */
51
+ export function buildStaticBundle(_a) {
52
+ return __awaiter(this, arguments, void 0, function* ({ entry, outDir, studioVersion, apiBase, accessToken, title = 'SFA/CRM', manifestExtras = {}, }) {
53
+ for (const [name, value] of [
54
+ ['entry', entry],
55
+ ['outDir', outDir],
56
+ ['studioVersion', studioVersion],
57
+ ['apiBase', apiBase],
58
+ ['accessToken', accessToken],
59
+ ]) {
60
+ // NOTE: checked before esbuild runs. An empty apiBase otherwise surfaces as
61
+ // "Failed to parse URL from /v1/pages/sign" after a full build.
62
+ // NOTE: `== null` as well as empty. The subpath is published, and a JS
63
+ // caller omitting a field would otherwise pay for a full build to reach
64
+ // `Authorization: Bearer undefined`.
65
+ if (value == null || value === '') {
66
+ throw new Error(`${name} is required and must not be empty`);
67
+ }
68
+ }
69
+ // Serialised now so a value that cannot be JSON — cyclic, a BigInt — fails
70
+ // before anything is written, rather than after Keystone has signed.
71
+ assertSerialisable(manifestExtras);
72
+ const esbuild = yield loadEsbuild();
73
+ yield prepareOutDir(outDir);
74
+ yield mkdir(join(outDir, 'assets'), { recursive: true });
75
+ try {
76
+ yield esbuild.build({
77
+ entryPoints: [entry],
78
+ outfile: join(outDir, BUNDLE_PATH),
79
+ bundle: true,
80
+ minify: true,
81
+ format: 'esm',
82
+ jsx: 'automatic',
83
+ target: 'es2020',
84
+ });
85
+ // entry が CSS を import していると esbuild が bundle.css も書き出す。
86
+ // index.html から参照しないと、署名も検証も通ったうえで無スタイルの画面が
87
+ // 出る — どこにも失敗が現れない
88
+ const hasStyles = yield stat(join(outDir, STYLES_PATH)).then(() => true, () => false);
89
+ yield writeFile(join(outDir, 'index.html'), renderIndexHtml(title, hasStyles), 'utf-8');
90
+ // manifest.json is written last: the hash covers every other file, so the
91
+ // directory has to be complete before it is computed.
92
+ const contentHash = computeContentHash(yield readBundleFiles(outDir));
93
+ const manifest = {
94
+ studio_version: studioVersion,
95
+ content_hash: contentHash,
96
+ signature: yield requestSignature({
97
+ apiBase,
98
+ accessToken,
99
+ studioVersion,
100
+ contentHash,
101
+ }),
102
+ };
103
+ yield writeFile(join(outDir, 'manifest.json'),
104
+ // NOTE: the decorative fields go first so the three signed ones cannot be
105
+ // overwritten by a caller's extras.
106
+ `${JSON.stringify(Object.assign(Object.assign({}, manifestExtras), manifest), null, 2)}\n`, 'utf-8');
107
+ return manifest;
108
+ }
109
+ catch (thrown) {
110
+ // Whatever was written is a bundle in every respect but the manifest, and
111
+ // a caller can still zip and upload it.
112
+ yield removeOwnOutput(outDir).catch(() => {
113
+ throw new Error(`${thrown instanceof Error ? thrown.message : String(thrown)} ` +
114
+ `Additionally, ${outDir} could not be cleaned up — delete it by ` +
115
+ 'hand rather than uploading it; it carries no signature.');
116
+ });
117
+ throw thrown;
118
+ }
119
+ });
120
+ }
121
+ /** Fails now on a value `JSON.stringify` would reject after the bundle is signed. */
122
+ function assertSerialisable(extras) {
123
+ try {
124
+ JSON.stringify(extras);
125
+ }
126
+ catch (thrown) {
127
+ throw new Error('manifestExtras must be JSON-serialisable ' +
128
+ `(${thrown instanceof Error ? thrown.message : String(thrown)})`);
129
+ }
130
+ }
131
+ /**
132
+ * @description Empties the output directory of previous build output, and
133
+ * refuses to touch anything else.
134
+ *
135
+ * NOTE: `outDir` comes from a caller's flag, so nothing is deleted recursively
136
+ * — `--out src` would take the source tree with it. Only the exact files this
137
+ * builder writes are removed, and any other file at any depth stops the build:
138
+ * a leftover lands in `content_hash` and then has to be in the zip for the
139
+ * signature to match, which quietly makes it part of the bundle.
140
+ *
141
+ * NOTE: matching those names is not enough to claim the directory. `index.html`
142
+ * beside `assets/bundle.js` is also what a hand-written static site looks like,
143
+ * and deleting one would be unrecoverable. A `manifest.json` carrying the three
144
+ * signed fields is the proof, because only this builder writes one.
145
+ */
146
+ function prepareOutDir(outDir) {
147
+ return __awaiter(this, void 0, void 0, function* () {
148
+ var _a;
149
+ // NOTE: names only. Reading contents here — as readBundleFiles does — would
150
+ // pull the whole directory into memory to decide it is the wrong directory,
151
+ // so `--out .` in a repository would run out of memory instead of refusing.
152
+ const entries = yield readdir(outDir, {
153
+ recursive: true,
154
+ withFileTypes: true,
155
+ }).catch((thrown) => {
156
+ // Only "it is not there yet" means there is nothing to check. Swallowing
157
+ // every error would turn a permission problem into a silent build over
158
+ // whatever the directory held.
159
+ if (thrown.code === 'ENOENT')
160
+ return null;
161
+ throw thrown;
162
+ });
163
+ if (entries == null)
164
+ return;
165
+ const unexpected = [];
166
+ const junk = [];
167
+ const own = [];
168
+ for (const entry of entries) {
169
+ if (!entry.isFile())
170
+ continue;
171
+ const parent = (_a = entry.parentPath) !== null && _a !== void 0 ? _a : entry.path;
172
+ const path = relative(outDir, join(parent, entry.name))
173
+ .split(/[\\/]/)
174
+ .join(posix.sep);
175
+ if (OWN_OUTPUT.has(path)) {
176
+ own.push(path);
177
+ continue;
178
+ }
179
+ // NOTE: collected, not deleted yet. Removing junk from a directory the
180
+ // next few lines may refuse to touch would contradict the promise above.
181
+ if (JUNK_FILES.has(entry.name)) {
182
+ junk.push(join(parent, entry.name));
183
+ continue;
184
+ }
185
+ unexpected.push(path);
186
+ if (unexpected.length > UNEXPECTED_SHOWN)
187
+ break;
188
+ }
189
+ if (unexpected.length > 0) {
190
+ const shown = unexpected.slice(0, UNEXPECTED_SHOWN).sort().join(', ');
191
+ const more = unexpected.length > UNEXPECTED_SHOWN ? ', and more' : '';
192
+ throw new Error(`${outDir} holds files this build did not produce ` +
193
+ `(${shown}${more}). They would be hashed into the bundle. ` +
194
+ 'Point --out at an empty directory, or clear it first.');
195
+ }
196
+ if (own.length > 0 && !(yield hasOwnManifest(outDir))) {
197
+ throw new Error(`${outDir} holds ${own.sort().join(', ')} but no manifest.json this ` +
198
+ 'builder wrote, so it is not a previous build of this bundle and will ' +
199
+ 'not be overwritten. Point --out at an empty directory, or clear it ' +
200
+ 'first.');
201
+ }
202
+ for (const path of junk) {
203
+ yield rm(path, { force: true });
204
+ }
205
+ yield removeOwnOutput(outDir);
206
+ });
207
+ }
208
+ /** Whether the directory carries a manifest only this builder could have written. */
209
+ function hasOwnManifest(outDir) {
210
+ return __awaiter(this, void 0, void 0, function* () {
211
+ const raw = yield readFile(join(outDir, 'manifest.json'), 'utf-8').catch(() => null);
212
+ if (raw == null)
213
+ return false;
214
+ try {
215
+ const parsed = JSON.parse(raw);
216
+ return ['studio_version', 'content_hash', 'signature'].every(field => typeof parsed[field] === 'string' && parsed[field] !== '');
217
+ }
218
+ catch (_a) {
219
+ return false;
220
+ }
221
+ });
222
+ }
223
+ /**
224
+ * @description Deletes the files this builder writes, and nothing else.
225
+ *
226
+ * NOTE: separate from {@link prepareOutDir} because the failure path needs the
227
+ * removal without the validation. Reusing the checking function there would
228
+ * throw on any stray file and leave the unsigned bundle exactly where the
229
+ * contract promises it will not be.
230
+ */
231
+ function removeOwnOutput(outDir) {
232
+ return __awaiter(this, void 0, void 0, function* () {
233
+ for (const path of OWN_OUTPUT) {
234
+ yield rm(join(outDir, path), { force: true });
235
+ }
236
+ });
237
+ }
238
+ /** The host page for the bundle: the script, and the stylesheet if there is one. */
239
+ function renderIndexHtml(title, hasStyles) {
240
+ const stylesheet = hasStyles
241
+ ? `\n <link rel="stylesheet" href="./${STYLES_PATH}" />`
242
+ : '';
243
+ return `<!doctype html>
244
+ <html lang="ja">
245
+ <head>
246
+ <meta charset="utf-8" />
247
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
248
+ <title>${escapeHtml(title)}</title>${stylesheet}
249
+ </head>
250
+ <body>
251
+ <div id="root"></div>
252
+ <script type="module" src="./${BUNDLE_PATH}"></script>
253
+ </body>
254
+ </html>
255
+ `;
256
+ }
257
+ function escapeHtml(value) {
258
+ return value
259
+ .replace(/&/g, '&amp;')
260
+ .replace(/</g, '&lt;')
261
+ .replace(/>/g, '&gt;')
262
+ .replace(/"/g, '&quot;');
263
+ }
@@ -0,0 +1,25 @@
1
+ /** The slice of esbuild's API the bundler uses. */
2
+ export interface EsbuildModule {
3
+ build(options: {
4
+ entryPoints: string[];
5
+ outfile: string;
6
+ bundle: boolean;
7
+ minify: boolean;
8
+ format: 'esm';
9
+ jsx: 'automatic';
10
+ target: string;
11
+ }): Promise<unknown>;
12
+ }
13
+ /**
14
+ * @description Loads esbuild at call time.
15
+ *
16
+ * esbuild is an optional peer: it is a build-time tool for the one caller that
17
+ * packages a static bundle, and adding it to `dependencies` would put a
18
+ * platform-specific binary into every install of a package whose runtime needs
19
+ * nothing but `tslib`.
20
+ *
21
+ * NOTE: the specifier goes through a variable so TypeScript does not resolve
22
+ * `esbuild` at compile time. Naming it directly would make the kit fail to
23
+ * build wherever esbuild is absent — which is everywhere it is not being used.
24
+ */
25
+ export declare function loadEsbuild(): Promise<EsbuildModule>;
@@ -0,0 +1,27 @@
1
+ import { __awaiter } from "tslib";
2
+ /**
3
+ * @description Loads esbuild at call time.
4
+ *
5
+ * esbuild is an optional peer: it is a build-time tool for the one caller that
6
+ * packages a static bundle, and adding it to `dependencies` would put a
7
+ * platform-specific binary into every install of a package whose runtime needs
8
+ * nothing but `tslib`.
9
+ *
10
+ * NOTE: the specifier goes through a variable so TypeScript does not resolve
11
+ * `esbuild` at compile time. Naming it directly would make the kit fail to
12
+ * build wherever esbuild is absent — which is everywhere it is not being used.
13
+ */
14
+ export function loadEsbuild() {
15
+ return __awaiter(this, void 0, void 0, function* () {
16
+ const specifier = 'esbuild';
17
+ try {
18
+ return (yield import(specifier));
19
+ }
20
+ catch (thrown) {
21
+ const reason = thrown instanceof Error ? thrown.message : String(thrown);
22
+ throw new Error('esbuild is required to build a static bundle but could not be loaded ' +
23
+ `(${reason}). Install it alongside @geniee-inc/sfa-kit ` +
24
+ '(npm install --save-dev esbuild).');
25
+ }
26
+ });
27
+ }
@@ -0,0 +1,2 @@
1
+ export type { BuildStaticBundleOptions } from './build-static-bundle.js';
2
+ export { buildStaticBundle } from './build-static-bundle.js';
@@ -0,0 +1 @@
1
+ export { buildStaticBundle } from './build-static-bundle.js';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @description Asks Keystone to sign a bundle, returning the signature.
3
+ *
4
+ * The signing key never leaves Keystone: deploying it to the page builder as
5
+ * well was the original design and was dropped because Studio's secret store is
6
+ * per project while the key must be identical across all of them (SFAJAI-186).
7
+ * The caller computes `contentHash` locally and Keystone signs it.
8
+ *
9
+ * NOTE: every failure throws. An unsigned bundle uploads successfully while
10
+ * Keystone's verifier runs in `disabled` mode, so returning "no signature" here
11
+ * would produce an artifact that looks fine and starts being rejected the day
12
+ * the mode is switched to `enforced` — with nothing on this side to explain it.
13
+ *
14
+ * @param apiBase - Keystone API base. A trailing slash is trimmed.
15
+ * @param accessToken - The same Bearer token the rest of the API is called with.
16
+ */
17
+ export declare function requestSignature({ apiBase, accessToken, studioVersion, contentHash, }: {
18
+ readonly apiBase: string;
19
+ readonly accessToken: string;
20
+ readonly studioVersion: string;
21
+ readonly contentHash: string;
22
+ }): Promise<string>;
@@ -0,0 +1,81 @@
1
+ import { __awaiter } from "tslib";
2
+ /**
3
+ * Both the hash sent and the signature returned are hex SHA-256 / HMAC-SHA-256
4
+ * digests, so the same shape applies in each direction.
5
+ */
6
+ const HEX_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
7
+ /** Matches the timeout the documented Python flow uses for the same call. */
8
+ const SIGN_TIMEOUT_MS = 30000;
9
+ /**
10
+ * @description Asks Keystone to sign a bundle, returning the signature.
11
+ *
12
+ * The signing key never leaves Keystone: deploying it to the page builder as
13
+ * well was the original design and was dropped because Studio's secret store is
14
+ * per project while the key must be identical across all of them (SFAJAI-186).
15
+ * The caller computes `contentHash` locally and Keystone signs it.
16
+ *
17
+ * NOTE: every failure throws. An unsigned bundle uploads successfully while
18
+ * Keystone's verifier runs in `disabled` mode, so returning "no signature" here
19
+ * would produce an artifact that looks fine and starts being rejected the day
20
+ * the mode is switched to `enforced` — with nothing on this side to explain it.
21
+ *
22
+ * @param apiBase - Keystone API base. A trailing slash is trimmed.
23
+ * @param accessToken - The same Bearer token the rest of the API is called with.
24
+ */
25
+ export function requestSignature(_a) {
26
+ return __awaiter(this, arguments, void 0, function* ({ apiBase, accessToken, studioVersion, contentHash, }) {
27
+ if (!HEX_DIGEST_PATTERN.test(contentHash)) {
28
+ throw new Error(`content_hash must be a 64-character hex digest, got ${JSON.stringify(contentHash)}`);
29
+ }
30
+ // 末尾のスラッシュを落とす。付いたままだと //v1/pages/sign になり、多くの
31
+ // ゲートウェイが 404 を返すため、設定ミスが認証の問題に見える
32
+ const base = apiBase.replace(/\/+$/, '');
33
+ const url = `${base}/v1/pages/sign`;
34
+ // NOTE: a bad host gives "fetch failed" and an empty apiBase "Failed to parse
35
+ // URL from /v1/pages/sign" — neither names the endpoint, the base, or the
36
+ // fact that the bundle is now unsigned.
37
+ const response = yield globalThis
38
+ .fetch(url, {
39
+ // NOTE: without a deadline a server that accepts the connection and never
40
+ // answers hangs the build, leaving an unsigned output directory behind.
41
+ signal: AbortSignal.timeout(SIGN_TIMEOUT_MS),
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ Authorization: `Bearer ${accessToken}`,
46
+ },
47
+ body: JSON.stringify({
48
+ studio_version: studioVersion,
49
+ content_hash: contentHash,
50
+ }),
51
+ })
52
+ .catch((thrown) => {
53
+ throw new Error(`POST ${url} could not be reached ` +
54
+ `(${thrown instanceof Error ? thrown.message : String(thrown)}). ` +
55
+ 'The bundle was not signed; check the API base and network access.');
56
+ });
57
+ if (!response.ok) {
58
+ const detail = yield response.text().catch(() => '');
59
+ // NOTE: the endpoint exists (SFAJAI-186), so a 404 is a wrong apiBase and a
60
+ // 401 a token from another org — not a reason to ship the bundle unsigned.
61
+ throw new Error(`POST /v1/pages/sign failed: ${response.status} ${response.statusText}` +
62
+ `${detail ? ` — ${detail}` : ''}. The bundle was not signed and must ` +
63
+ 'not be uploaded; check the API base and the access token.');
64
+ }
65
+ // NOTE: a proxy answering 200 with an HTML page would throw a bare
66
+ // SyntaxError here, escaping the messages built for every other failure.
67
+ const payload = (yield response.json().catch(() => null));
68
+ if (payload == null || typeof payload.signature !== 'string') {
69
+ throw new Error(`POST ${url} returned no signature. The bundle was not signed.`);
70
+ }
71
+ // NOTE: the hash sent is validated, so the signature coming back is too. An
72
+ // error page echoed into this field would otherwise be written into
73
+ // manifest.json, upload fine while the verifier is `disabled`, and fail only
74
+ // once it is switched to `enforced`.
75
+ if (!HEX_DIGEST_PATTERN.test(payload.signature)) {
76
+ throw new Error(`POST ${url} returned ${JSON.stringify(payload.signature)}, which is ` +
77
+ 'not an HMAC-SHA-256 hex digest. The bundle was not signed.');
78
+ }
79
+ return payload.signature;
80
+ });
81
+ }
@@ -8,7 +8,9 @@ export interface BulkRowSplit<T> {
8
8
  readonly conflicts: readonly SfaBulkRowResult[];
9
9
  /**
10
10
  * The rows that did not land and can be sent again. A `conflict` is one of
11
- * them, and also appears in `conflicts`.
11
+ * them, and also appears in `conflicts`. Read each row's `status` to say why
12
+ * it failed: `not_found` and `not_permitted` are settled refusals that a
13
+ * resend alone will not clear.
12
14
  */
13
15
  readonly failed: readonly SfaBulkRowResult[];
14
16
  }
@@ -16,9 +18,11 @@ export interface BulkRowSplit<T> {
16
18
  * @description Splits per-row outcomes by what the caller may do with them.
17
19
  *
18
20
  * NOTE: `failed` is decided by what the answer says, never by exclusion.
19
- * `conflict`, `invalid` and `not_attempted` all mean the row did not apply, so
20
- * those are the ones safe to send again — a row the call never started is as
21
- * unwritten as one the server rejected. Anything else the kit cannot place —
21
+ * `conflict`, `invalid`, `not_attempted`, `not_found` and `not_permitted` all
22
+ * mean the row did not apply, so those are the ones safe to send again — a row
23
+ * the call never started is as unwritten as one the server refused. Each keeps
24
+ * its own `status`, so a caller can tell "no permission" from "failed
25
+ * validation" without a bucket per reason. Anything else the kit cannot place —
22
26
  * `indeterminate`, no record, no version, a status outside the contract or
23
27
  * belonging to another operation — may already be written, and a retry would
24
28
  * write it twice.
@@ -1,16 +1,29 @@
1
- /** The outcomes that mean the row did not apply, so sending it again is safe. */
1
+ /**
2
+ * The outcomes that mean the row did not apply, so sending it again is safe.
3
+ *
4
+ * NOTE: safe to send again, not sure to succeed. `invalid` needs the row
5
+ * corrected first, `conflict` a fresh `version`, and `not_found` /
6
+ * `not_permitted` answer the same until the record or the permission changes.
7
+ * What the set decides is that no write happened, so the caller may resend
8
+ * without writing a second record — which is the question `unresolved` exists
9
+ * to answer, and the reason these belong in `failed` instead.
10
+ */
2
11
  const RETRYABLE_STATUSES = new Set([
3
12
  'conflict',
4
13
  'invalid',
5
14
  'not_attempted',
15
+ 'not_found',
16
+ 'not_permitted',
6
17
  ]);
7
18
  /**
8
19
  * @description Splits per-row outcomes by what the caller may do with them.
9
20
  *
10
21
  * NOTE: `failed` is decided by what the answer says, never by exclusion.
11
- * `conflict`, `invalid` and `not_attempted` all mean the row did not apply, so
12
- * those are the ones safe to send again — a row the call never started is as
13
- * unwritten as one the server rejected. Anything else the kit cannot place —
22
+ * `conflict`, `invalid`, `not_attempted`, `not_found` and `not_permitted` all
23
+ * mean the row did not apply, so those are the ones safe to send again — a row
24
+ * the call never started is as unwritten as one the server refused. Each keeps
25
+ * its own `status`, so a caller can tell "no permission" from "failed
26
+ * validation" without a bucket per reason. Anything else the kit cannot place —
14
27
  * `indeterminate`, no record, no version, a status outside the contract or
15
28
  * belonging to another operation — may already be written, and a retry would
16
29
  * write it twice.
@@ -18,7 +18,9 @@ export interface UseBulkCreateRecordsResult {
18
18
  /**
19
19
  * The rows that did not land and can be sent again, with their `index` and
20
20
  * `error`. A batch can be partly successful, so this being non-empty does
21
- * not mean nothing was written — retry exactly these rows.
21
+ * not mean nothing was written. Read each row's `status` before retrying:
22
+ * `not_found` and `not_permitted` are settled refusals that come back the
23
+ * same however often they are resent.
22
24
  */
23
25
  readonly failed: readonly SfaBulkRowResult[];
24
26
  /**
@@ -50,7 +52,10 @@ export interface UseBulkCreateRecordsResult {
50
52
  * the call as a whole.
51
53
  *
52
54
  * Retry only the rows in `failed`: those did not apply, which is what makes
53
- * resending them safe. Re-read the rows in `unresolved` instead — nothing
54
- * protects against writing them twice.
55
+ * resending them safe — safe, not sure to succeed. A `not_found` or
56
+ * `not_permitted` row is refused again until the record or the permission
57
+ * changes, so branch on `status` rather than offering one retry for the whole
58
+ * bucket. Re-read the rows in `unresolved` instead — nothing protects against
59
+ * writing them twice.
55
60
  */
56
61
  export declare function useBulkCreateRecords(collection: string): UseBulkCreateRecordsResult;
@@ -14,8 +14,11 @@ const sendBulkCreate = (client, collection, rows) => client.bulkCreate(collectio
14
14
  * the call as a whole.
15
15
  *
16
16
  * Retry only the rows in `failed`: those did not apply, which is what makes
17
- * resending them safe. Re-read the rows in `unresolved` instead — nothing
18
- * protects against writing them twice.
17
+ * resending them safe — safe, not sure to succeed. A `not_found` or
18
+ * `not_permitted` row is refused again until the record or the permission
19
+ * changes, so branch on `status` rather than offering one retry for the whole
20
+ * bucket. Re-read the rows in `unresolved` instead — nothing protects against
21
+ * writing them twice.
19
22
  */
20
23
  export function useBulkCreateRecords(collection) {
21
24
  const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkCreate);
@@ -20,7 +20,16 @@ export interface UseBulkDeleteRecordsResult {
20
20
  * whether it still wants the record gone.
21
21
  */
22
22
  readonly conflicts: readonly SfaBulkRowResult[];
23
- /** The rows still in place that can be sent again, conflicts included. */
23
+ /**
24
+ * The rows that were not removed by this call, conflicts included.
25
+ *
26
+ * NOTE: not all of them are still in place. A `not_found` row says the
27
+ * record was not there to delete — already gone, or never existing. It is
28
+ * reported here rather than in `deletedIds` because this call removed
29
+ * nothing, and the kit will not claim a deletion the server did not make.
30
+ * A caller whose grid wants the row cleared checks for that `status`;
31
+ * `not_permitted` is the one that leaves the record in place.
32
+ */
24
33
  readonly failed: readonly SfaBulkRowResult[];
25
34
  /**
26
35
  * The rows that may already be removed but cannot be reported either way.
@@ -46,5 +55,9 @@ export interface UseBulkDeleteRecordsResult {
46
55
  * place while the rest of the batch applies. There is no rollback, so a
47
56
  * partly failed batch has already removed records — which is why per-row
48
57
  * outcomes are returned as data rather than thrown.
58
+ *
59
+ * Retry only the rows in `failed`, and read each row's `status` first: a
60
+ * `not_found` row was not there to delete and a `not_permitted` one may not be
61
+ * deleted, so neither clears on a resend. Rows in `unresolved` are re-read.
49
62
  */
50
63
  export declare function useBulkDeleteRecords(collection: string): UseBulkDeleteRecordsResult;
@@ -11,6 +11,10 @@ const sendBulkDelete = (client, collection, rows) => client.bulkDelete(collectio
11
11
  * place while the rest of the batch applies. There is no rollback, so a
12
12
  * partly failed batch has already removed records — which is why per-row
13
13
  * outcomes are returned as data rather than thrown.
14
+ *
15
+ * Retry only the rows in `failed`, and read each row's `status` first: a
16
+ * `not_found` row was not there to delete and a `not_permitted` one may not be
17
+ * deleted, so neither clears on a resend. Rows in `unresolved` are re-read.
14
18
  */
15
19
  export function useBulkDeleteRecords(collection) {
16
20
  const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkDelete);
@@ -21,7 +21,11 @@ export interface UseBulkUpdateRecordsResult {
21
21
  * without a `version`, re-read it first — there is nothing to lock against.
22
22
  */
23
23
  readonly conflicts: readonly SfaBulkRowResult[];
24
- /** The rows that did not land and can be sent again, conflicts included. */
24
+ /**
25
+ * The rows that did not land and can be sent again, conflicts included.
26
+ * Read each row's `status`: `not_found` and `not_permitted` are settled
27
+ * refusals, and a resend alone will not clear them.
28
+ */
25
29
  readonly failed: readonly SfaBulkRowResult[];
26
30
  /**
27
31
  * The rows that may already be applied but cannot be reported either way.
@@ -49,7 +53,9 @@ export interface UseBulkUpdateRecordsResult {
49
53
  * as data rather than thrown errors.
50
54
  *
51
55
  * Retry only the rows in `failed`. A conflict is retried with the version from
52
- * its `record`, never by re-sending the stale one. Rows in `unresolved` are
56
+ * its `record`, never by re-sending the stale one. A `not_found` or
57
+ * `not_permitted` row is refused again until the record or the permission
58
+ * changes — report it rather than offering a retry. Rows in `unresolved` are
53
59
  * re-read, not retried.
54
60
  */
55
61
  export declare function useBulkUpdateRecords(collection: string): UseBulkUpdateRecordsResult;
@@ -13,7 +13,9 @@ const sendBulkUpdate = (client, collection, rows) => client.bulkUpdate(collectio
13
13
  * as data rather than thrown errors.
14
14
  *
15
15
  * Retry only the rows in `failed`. A conflict is retried with the version from
16
- * its `record`, never by re-sending the stale one. Rows in `unresolved` are
16
+ * its `record`, never by re-sending the stale one. A `not_found` or
17
+ * `not_permitted` row is refused again until the record or the permission
18
+ * changes — report it rather than offering a retry. Rows in `unresolved` are
17
19
  * re-read, not retried.
18
20
  */
19
21
  export function useBulkUpdateRecords(collection) {
@@ -35,6 +35,7 @@ export declare class SfaClient implements SfaClientInterface {
35
35
  /** In-memory cache for describe results, keyed by objectKey. Stores Promises
36
36
  * so concurrent callers share a single in-flight request. */
37
37
  private readonly describeCache;
38
+ private listObjectsCache;
38
39
  constructor(config: SfaClientConfig);
39
40
  private resolveBase;
40
41
  get(collection: string, recordId: string): Promise<SfaRecord>;