cursedbelt-core 2.9.0 → 2.11.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/dist/core/searchPattern.d.ts +115 -0
- package/dist/core/searchPattern.js +146 -0
- package/dist/ctgrClient/convertRequest.js +4 -3
- package/dist/ctgrClient/ctgrGuard.js +0 -2
- package/dist/ctgrClient/index.d.ts +1 -0
- package/dist/ctgrClient/index.js +3 -1
- package/dist/ctgrClient/sendRequests.d.ts +12 -0
- package/dist/ctgrClient/sendRequests.js +44 -0
- package/package.json +16 -6
- package/src/core/offline/shellWorker.webworker.spec.ts +4 -2
- package/src/core/searchPattern.spec.ts +130 -0
- package/src/core/searchPattern.ts +179 -0
- package/src/ctgrClient/convertRequest.ts +4 -3
- package/src/ctgrClient/ctgrGuard.ts +0 -2
- package/src/ctgrClient/index.ts +8 -1
- package/src/ctgrClient/sendRequests.spec.ts +85 -0
- package/src/ctgrClient/sendRequests.ts +87 -0
- package/src/leafSubpathsImportNothing.spec.ts +10 -6
- package/src/lockfileIsTracked.spec.ts +9 -5
- package/src/shippedFilesAreTracked.spec.ts +8 -8
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a search box's text MEANS — plain words, a `*` wildcard, or a regular
|
|
3
|
+
* expression — decided once, in one place, for the browser and the server alike.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (owner request via flix, 2026-09-28) ────────────────────
|
|
6
|
+
* *"type * to mean any character in the search. Then if I know two words in a
|
|
7
|
+
* movie but not what is between them I can put the * between them like
|
|
8
|
+
* "the*the" could pull up "The hunt for the red October" … but not … "The mask".
|
|
9
|
+
* Also "hunt*red" would find "The hunt for the red October".*
|
|
10
|
+
*
|
|
11
|
+
* A word index cannot answer that: it knows the title contains `the`, not that it
|
|
12
|
+
* contains it TWICE, and not in which order its words came. So a `*` turns the
|
|
13
|
+
* search into an ordered substring match — `the*the` is "`the`, then anything,
|
|
14
|
+
* then `the`" — and a search without one is untouched, because every app already
|
|
15
|
+
* has a plain search it has tuned (flix ranks by FTS5 + bm25; see its
|
|
16
|
+
* `src/server/library.ts`).
|
|
17
|
+
*
|
|
18
|
+
* ── The semantics, all of them ──────────────────────────────────────────────
|
|
19
|
+
* - **Contains, never anchored.** `hunt*red` matches *The Hunt for Red October*
|
|
20
|
+
* although the title does not START with `hunt`. So leading and trailing stars
|
|
21
|
+
* are no-ops: `*clov*` ≡ `clov*` ≡ `clov` in what they match — but only the
|
|
22
|
+
* starred forms are WILDCARD searches, which is what lets an app answer `*over*`
|
|
23
|
+
* with *Cloverfield* (a substring inside a word) where its word index could not.
|
|
24
|
+
* - **Literal between the stars.** Spaces included, so `the *the` asks for a
|
|
25
|
+
* `the` followed by a space — a power user's word boundary, and nobody else
|
|
26
|
+
* notices. A part that is ONLY whitespace (`hunt * * red`) is dropped, because
|
|
27
|
+
* it would demand two separate gaps and fail on titles it plainly describes.
|
|
28
|
+
* - **Case-insensitive, always** — in the matcher and the regex alike. A search
|
|
29
|
+
* box that cares about capitals reads as broken.
|
|
30
|
+
* - **Regex is opt-in** (`regex: true`), and only in the unmistakable `/…/flags`
|
|
31
|
+
* form, so `AC/DC` is never mistaken for one. Its matcher runs where the data is
|
|
32
|
+
* in memory; there is deliberately no SQL form (SQLite and D1 ship no REGEXP),
|
|
33
|
+
* and a server that ever evaluates one must bound it — a viewer-typed regex on a
|
|
34
|
+
* shared server is a denial-of-service vector.
|
|
35
|
+
*
|
|
36
|
+
* ── How an app uses it ──────────────────────────────────────────────────────
|
|
37
|
+
* {@link parseSearchPattern} is the whole decision; everything else derives from
|
|
38
|
+
* its result. An app that filters in memory calls {@link createSearchMatcher}. An
|
|
39
|
+
* app that queries SQL asks {@link searchPatternToLike} for a `LIKE` pattern, and
|
|
40
|
+
* keeps its own tuned path for `kind: 'plain'`. The React `SearchInput` in
|
|
41
|
+
* `cursedbelt/react` parses with the SAME function, so the badge it shows is the
|
|
42
|
+
* syntax the query actually ran in.
|
|
43
|
+
*
|
|
44
|
+
* Isomorphic and dependency-free — it imports nothing.
|
|
45
|
+
*/
|
|
46
|
+
/** A syntax an app can switch on beyond plain words. */
|
|
47
|
+
export type SearchSyntax = 'wildcard' | 'regex';
|
|
48
|
+
/** Which syntaxes a search box understands. Absent means the defaults below. */
|
|
49
|
+
export interface SearchSyntaxOptions {
|
|
50
|
+
/** `*` stands for any run of characters (including none). Default **on**. */
|
|
51
|
+
wildcard?: boolean;
|
|
52
|
+
/** `/source/flags` is a regular expression. Default **off**. */
|
|
53
|
+
regex?: boolean;
|
|
54
|
+
}
|
|
55
|
+
export declare const DEFAULT_SEARCH_SYNTAX: Readonly<Required<SearchSyntaxOptions>>;
|
|
56
|
+
/** The wildcard character. One constant, so the help text can never disagree with the parser. */
|
|
57
|
+
export declare const SEARCH_WILDCARD = "*";
|
|
58
|
+
export type SearchPattern =
|
|
59
|
+
/** Nothing to search for — show everything. A lone `*` is this too. */
|
|
60
|
+
{
|
|
61
|
+
kind: 'empty';
|
|
62
|
+
raw: string;
|
|
63
|
+
}
|
|
64
|
+
/** Ordinary words; `text` is trimmed with runs of whitespace collapsed. */
|
|
65
|
+
| {
|
|
66
|
+
kind: 'plain';
|
|
67
|
+
raw: string;
|
|
68
|
+
text: string;
|
|
69
|
+
}
|
|
70
|
+
/** `parts` must appear in this order, anything between them. Never empty. */
|
|
71
|
+
| {
|
|
72
|
+
kind: 'wildcard';
|
|
73
|
+
raw: string;
|
|
74
|
+
text: string;
|
|
75
|
+
parts: string[];
|
|
76
|
+
} | {
|
|
77
|
+
kind: 'regex';
|
|
78
|
+
raw: string;
|
|
79
|
+
source: string;
|
|
80
|
+
flags: string;
|
|
81
|
+
}
|
|
82
|
+
/** The viewer's text uses a syntax but cannot be compiled — say why, match nothing. */
|
|
83
|
+
| {
|
|
84
|
+
kind: 'invalid';
|
|
85
|
+
raw: string;
|
|
86
|
+
syntax: SearchSyntax;
|
|
87
|
+
message: string;
|
|
88
|
+
};
|
|
89
|
+
/** Decide what a search box's text means under `options`. */
|
|
90
|
+
export declare function parseSearchPattern(raw: string, options?: SearchSyntaxOptions): SearchPattern;
|
|
91
|
+
/**
|
|
92
|
+
* The pattern as a `RegExp`, or `null` when it matches everything (`empty`) or
|
|
93
|
+
* nothing (`invalid`). Plain text becomes a case-insensitive contains.
|
|
94
|
+
*/
|
|
95
|
+
export declare function searchPatternToRegExp(pattern: SearchPattern): RegExp | null;
|
|
96
|
+
/** Does `text` match? `empty` matches everything; `invalid` matches nothing. */
|
|
97
|
+
export declare function matchesSearchPattern(pattern: SearchPattern, text: string): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Parse once, compile once, then test many — the shape an in-memory list filter
|
|
100
|
+
* wants. `matcher(title)` for every row costs one regex test, not one parse.
|
|
101
|
+
*/
|
|
102
|
+
export declare function createSearchMatcher(raw: string, options?: SearchSyntaxOptions): {
|
|
103
|
+
pattern: SearchPattern;
|
|
104
|
+
test: (text: string) => boolean;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* The pattern as a SQL `LIKE` pattern, escaped with `escape` — so the query must
|
|
108
|
+
* say `LIKE ? ESCAPE '\'` (or whatever `escape` is). `null` for `empty`, `regex`
|
|
109
|
+
* and `invalid`: none of those has a `LIKE` form, and the caller decides what
|
|
110
|
+
* each means rather than being handed a pattern that silently matches too much.
|
|
111
|
+
*
|
|
112
|
+
* Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
|
|
113
|
+
* a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
|
|
114
|
+
*/
|
|
115
|
+
export declare function searchPatternToLike(pattern: SearchPattern, escapeChar?: string): string | null;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a search box's text MEANS — plain words, a `*` wildcard, or a regular
|
|
3
|
+
* expression — decided once, in one place, for the browser and the server alike.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (owner request via flix, 2026-09-28) ────────────────────
|
|
6
|
+
* *"type * to mean any character in the search. Then if I know two words in a
|
|
7
|
+
* movie but not what is between them I can put the * between them like
|
|
8
|
+
* "the*the" could pull up "The hunt for the red October" … but not … "The mask".
|
|
9
|
+
* Also "hunt*red" would find "The hunt for the red October".*
|
|
10
|
+
*
|
|
11
|
+
* A word index cannot answer that: it knows the title contains `the`, not that it
|
|
12
|
+
* contains it TWICE, and not in which order its words came. So a `*` turns the
|
|
13
|
+
* search into an ordered substring match — `the*the` is "`the`, then anything,
|
|
14
|
+
* then `the`" — and a search without one is untouched, because every app already
|
|
15
|
+
* has a plain search it has tuned (flix ranks by FTS5 + bm25; see its
|
|
16
|
+
* `src/server/library.ts`).
|
|
17
|
+
*
|
|
18
|
+
* ── The semantics, all of them ──────────────────────────────────────────────
|
|
19
|
+
* - **Contains, never anchored.** `hunt*red` matches *The Hunt for Red October*
|
|
20
|
+
* although the title does not START with `hunt`. So leading and trailing stars
|
|
21
|
+
* are no-ops: `*clov*` ≡ `clov*` ≡ `clov` in what they match — but only the
|
|
22
|
+
* starred forms are WILDCARD searches, which is what lets an app answer `*over*`
|
|
23
|
+
* with *Cloverfield* (a substring inside a word) where its word index could not.
|
|
24
|
+
* - **Literal between the stars.** Spaces included, so `the *the` asks for a
|
|
25
|
+
* `the` followed by a space — a power user's word boundary, and nobody else
|
|
26
|
+
* notices. A part that is ONLY whitespace (`hunt * * red`) is dropped, because
|
|
27
|
+
* it would demand two separate gaps and fail on titles it plainly describes.
|
|
28
|
+
* - **Case-insensitive, always** — in the matcher and the regex alike. A search
|
|
29
|
+
* box that cares about capitals reads as broken.
|
|
30
|
+
* - **Regex is opt-in** (`regex: true`), and only in the unmistakable `/…/flags`
|
|
31
|
+
* form, so `AC/DC` is never mistaken for one. Its matcher runs where the data is
|
|
32
|
+
* in memory; there is deliberately no SQL form (SQLite and D1 ship no REGEXP),
|
|
33
|
+
* and a server that ever evaluates one must bound it — a viewer-typed regex on a
|
|
34
|
+
* shared server is a denial-of-service vector.
|
|
35
|
+
*
|
|
36
|
+
* ── How an app uses it ──────────────────────────────────────────────────────
|
|
37
|
+
* {@link parseSearchPattern} is the whole decision; everything else derives from
|
|
38
|
+
* its result. An app that filters in memory calls {@link createSearchMatcher}. An
|
|
39
|
+
* app that queries SQL asks {@link searchPatternToLike} for a `LIKE` pattern, and
|
|
40
|
+
* keeps its own tuned path for `kind: 'plain'`. The React `SearchInput` in
|
|
41
|
+
* `cursedbelt/react` parses with the SAME function, so the badge it shows is the
|
|
42
|
+
* syntax the query actually ran in.
|
|
43
|
+
*
|
|
44
|
+
* Isomorphic and dependency-free — it imports nothing.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_SEARCH_SYNTAX = Object.freeze({
|
|
47
|
+
wildcard: true,
|
|
48
|
+
regex: false,
|
|
49
|
+
});
|
|
50
|
+
/** The wildcard character. One constant, so the help text can never disagree with the parser. */
|
|
51
|
+
export const SEARCH_WILDCARD = '*';
|
|
52
|
+
const REGEX_FORM = /^\/(.+)\/([a-z]*)$/;
|
|
53
|
+
/** Flags that change what matches. `g` and `y` are dropped: they make `test()` stateful. */
|
|
54
|
+
const REGEX_FLAGS = new Set(['i', 'm', 's', 'u', 'g', 'y']);
|
|
55
|
+
const collapse = (text) => text.replace(/\s+/g, ' ');
|
|
56
|
+
/** Decide what a search box's text means under `options`. */
|
|
57
|
+
export function parseSearchPattern(raw, options = {}) {
|
|
58
|
+
const syntax = { ...DEFAULT_SEARCH_SYNTAX, ...options };
|
|
59
|
+
const text = collapse(raw.trim());
|
|
60
|
+
if (text.length === 0)
|
|
61
|
+
return { kind: 'empty', raw };
|
|
62
|
+
if (syntax.regex) {
|
|
63
|
+
const form = REGEX_FORM.exec(text);
|
|
64
|
+
if (form) {
|
|
65
|
+
const [, source = '', given = ''] = form;
|
|
66
|
+
const unknown = [...given].find((f) => !REGEX_FLAGS.has(f));
|
|
67
|
+
if (unknown) {
|
|
68
|
+
return { kind: 'invalid', raw, syntax: 'regex', message: `Unknown regex flag "${unknown}"` };
|
|
69
|
+
}
|
|
70
|
+
const flags = [...new Set(['i', ...given])].filter((f) => f !== 'g' && f !== 'y').join('');
|
|
71
|
+
try {
|
|
72
|
+
new RegExp(source, flags);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
return { kind: 'invalid', raw, syntax: 'regex', message };
|
|
77
|
+
}
|
|
78
|
+
return { kind: 'regex', raw, source, flags };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (syntax.wildcard && text.includes(SEARCH_WILDCARD)) {
|
|
82
|
+
const parts = text.split(SEARCH_WILDCARD).filter((part) => part.trim().length > 0);
|
|
83
|
+
if (parts.length === 0)
|
|
84
|
+
return { kind: 'empty', raw };
|
|
85
|
+
return { kind: 'wildcard', raw, text, parts };
|
|
86
|
+
}
|
|
87
|
+
return { kind: 'plain', raw, text };
|
|
88
|
+
}
|
|
89
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
90
|
+
/**
|
|
91
|
+
* The pattern as a `RegExp`, or `null` when it matches everything (`empty`) or
|
|
92
|
+
* nothing (`invalid`). Plain text becomes a case-insensitive contains.
|
|
93
|
+
*/
|
|
94
|
+
export function searchPatternToRegExp(pattern) {
|
|
95
|
+
switch (pattern.kind) {
|
|
96
|
+
case 'empty':
|
|
97
|
+
case 'invalid':
|
|
98
|
+
return null;
|
|
99
|
+
case 'plain':
|
|
100
|
+
return new RegExp(escapeRegExp(pattern.text), 'i');
|
|
101
|
+
case 'wildcard':
|
|
102
|
+
// `[\s\S]*?` rather than `.*` so a gap may cross a newline in a
|
|
103
|
+
// multi-line field (a summary, a note) without needing the `s` flag.
|
|
104
|
+
return new RegExp(pattern.parts.map(escapeRegExp).join('[\\s\\S]*?'), 'i');
|
|
105
|
+
case 'regex':
|
|
106
|
+
return new RegExp(pattern.source, pattern.flags);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Does `text` match? `empty` matches everything; `invalid` matches nothing. */
|
|
110
|
+
export function matchesSearchPattern(pattern, text) {
|
|
111
|
+
if (pattern.kind === 'empty')
|
|
112
|
+
return true;
|
|
113
|
+
return searchPatternToRegExp(pattern)?.test(text) ?? false;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Parse once, compile once, then test many — the shape an in-memory list filter
|
|
117
|
+
* wants. `matcher(title)` for every row costs one regex test, not one parse.
|
|
118
|
+
*/
|
|
119
|
+
export function createSearchMatcher(raw, options = {}) {
|
|
120
|
+
const pattern = parseSearchPattern(raw, options);
|
|
121
|
+
if (pattern.kind === 'empty')
|
|
122
|
+
return { pattern, test: () => true };
|
|
123
|
+
const re = searchPatternToRegExp(pattern);
|
|
124
|
+
return { pattern, test: re ? (text) => re.test(text) : () => false };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The pattern as a SQL `LIKE` pattern, escaped with `escape` — so the query must
|
|
128
|
+
* say `LIKE ? ESCAPE '\'` (or whatever `escape` is). `null` for `empty`, `regex`
|
|
129
|
+
* and `invalid`: none of those has a `LIKE` form, and the caller decides what
|
|
130
|
+
* each means rather than being handed a pattern that silently matches too much.
|
|
131
|
+
*
|
|
132
|
+
* Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
|
|
133
|
+
* a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
|
|
134
|
+
*/
|
|
135
|
+
export function searchPatternToLike(pattern, escapeChar = '\\') {
|
|
136
|
+
const literal = (text) => text.replace(/[%_]/g, (c) => `${escapeChar}${c}`);
|
|
137
|
+
const escapeAll = (text) => literal(text.split(escapeChar).join(`${escapeChar}${escapeChar}`));
|
|
138
|
+
switch (pattern.kind) {
|
|
139
|
+
case 'plain':
|
|
140
|
+
return `%${escapeAll(pattern.text)}%`;
|
|
141
|
+
case 'wildcard':
|
|
142
|
+
return `%${pattern.parts.map(escapeAll).join('%')}%`;
|
|
143
|
+
default:
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { encodeRequest, METHOD_TO_ABBREV, toBase64, } from '../ctgr/index.js';
|
|
10
10
|
import { isCtgrModeOn } from './ctgrConfig.js';
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
11
|
+
// 7 MB per-file cap for GET-only mode. A file rides as base64 inside JSON — a third
|
|
12
|
+
// larger — and both chunk stores refuse a payload over 10 MB, so a 10 MB file was let
|
|
13
|
+
// through here only to be refused by the server after its first chunk.
|
|
14
|
+
const CTGR_MAX_FILE_BYTES = 7 * 1024 * 1024;
|
|
14
15
|
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
15
16
|
/**
|
|
16
17
|
* Convert one request for the tunnel. Returns the chunked GET requests to send in
|
|
@@ -70,13 +70,11 @@ export const installCtgrGuard = () => {
|
|
|
70
70
|
// ── XMLHttpRequest ───────────────────────────────────────────────────────────
|
|
71
71
|
if (typeof XMLHttpRequest !== 'undefined') {
|
|
72
72
|
const originalOpen = XMLHttpRequest.prototype.open;
|
|
73
|
-
// biome-ignore lint/suspicious/noExplicitAny: matching XHR.open's variadic overloads
|
|
74
73
|
XMLHttpRequest.prototype.open = function open(method, url, ...rest) {
|
|
75
74
|
const upper = String(method).toUpperCase();
|
|
76
75
|
if (isCtgrModeOn() && !SAFE_METHODS.has(upper) && isGuardedPath(String(url))) {
|
|
77
76
|
throw block();
|
|
78
77
|
}
|
|
79
|
-
// biome-ignore lint/suspicious/noExplicitAny: forwarding the original variadic call
|
|
80
78
|
return originalOpen.call(this, method, url, ...rest);
|
|
81
79
|
};
|
|
82
80
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
|
|
2
2
|
export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
|
|
3
3
|
export { installCtgrGuard } from './ctgrGuard.js';
|
|
4
|
+
export { CTGR_INCOMPLETE_STATUS, type CtgrSend, type SendCtgrOptions, sendCtgrRequests, } from './sendRequests.js';
|
package/dist/ctgrClient/index.js
CHANGED
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
* GET-only client:
|
|
4
4
|
* • the per-device mode flag + the `?ctgr=t|f` boot toggle (`ctgrConfig`),
|
|
5
5
|
* • the request converter that rewrites a mutation into `/ctgr<xx>` GET twins,
|
|
6
|
+
* • the sender that puts those twins on the wire and answers with the one real response,
|
|
6
7
|
* • the network-boundary guard that blocks a stray raw verb.
|
|
7
8
|
*
|
|
8
9
|
* The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
|
|
9
10
|
* `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
|
|
10
11
|
* `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
|
|
11
|
-
* through `convertIfOnlyGETRequests`.
|
|
12
|
+
* through `convertIfOnlyGETRequests`, then `sendCtgrRequests`.
|
|
12
13
|
*/
|
|
13
14
|
export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
|
|
14
15
|
export { convertIfOnlyGETRequests } from './convertRequest.js';
|
|
15
16
|
export { installCtgrGuard } from './ctgrGuard.js';
|
|
17
|
+
export { CTGR_INCOMPLETE_STATUS, sendCtgrRequests, } from './sendRequests.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CtgrFetchRequest } from './convertRequest.js';
|
|
2
|
+
/** How the caller puts one request on the wire — its own `fetch`, with its own headers. */
|
|
3
|
+
export type CtgrSend = (request: CtgrFetchRequest) => Promise<Response>;
|
|
4
|
+
export interface SendCtgrOptions {
|
|
5
|
+
/** Chunks in flight at once. Default 4 — fast, and gentle on an edge's rate limits. */
|
|
6
|
+
concurrency?: number;
|
|
7
|
+
/** Told after each chunk is acknowledged, for a progress bar: `(sent, total)`. */
|
|
8
|
+
onProgress?: (sent: number, total: number) => void;
|
|
9
|
+
}
|
|
10
|
+
/** The status a last chunk is given when the server still answers "waiting for more". */
|
|
11
|
+
export declare const CTGR_INCOMPLETE_STATUS = 502;
|
|
12
|
+
export declare function sendCtgrRequests(requests: readonly CtgrFetchRequest[], send: CtgrSend, options?: SendCtgrOptions): Promise<Response>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** The status a last chunk is given when the server still answers "waiting for more". */
|
|
2
|
+
export const CTGR_INCOMPLETE_STATUS = 502;
|
|
3
|
+
export async function sendCtgrRequests(requests, send, options = {}) {
|
|
4
|
+
const first = requests[0];
|
|
5
|
+
if (!first)
|
|
6
|
+
throw new Error('ctgr: no request to send');
|
|
7
|
+
const total = requests.length;
|
|
8
|
+
if (total === 1) {
|
|
9
|
+
const only = await send(first);
|
|
10
|
+
options.onProgress?.(1, 1);
|
|
11
|
+
return only;
|
|
12
|
+
}
|
|
13
|
+
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
|
|
14
|
+
const pieces = requests.slice(0, -1);
|
|
15
|
+
let next = 0;
|
|
16
|
+
let sent = 0;
|
|
17
|
+
let refusal = null;
|
|
18
|
+
const worker = async () => {
|
|
19
|
+
while (refusal === null && next < pieces.length) {
|
|
20
|
+
const piece = pieces[next++];
|
|
21
|
+
const response = await send(piece);
|
|
22
|
+
if (response.status !== 202 && !response.ok) {
|
|
23
|
+
refusal ??= response;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// An intermediate's body is `{acknowledged:true}`; read it so the connection frees.
|
|
27
|
+
await response.body?.cancel().catch(() => undefined);
|
|
28
|
+
options.onProgress?.(++sent, total);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, pieces.length) }, worker));
|
|
32
|
+
if (refusal)
|
|
33
|
+
return refusal;
|
|
34
|
+
const last = await send(requests[total - 1]);
|
|
35
|
+
if (last.status === 202) {
|
|
36
|
+
await last.body?.cancel().catch(() => undefined);
|
|
37
|
+
return Response.json({
|
|
38
|
+
error: 'The server did not receive every piece of this request — nothing was saved. Send it again.',
|
|
39
|
+
code: 'CTGR_INCOMPLETE',
|
|
40
|
+
}, { status: CTGR_INCOMPLETE_STATUS });
|
|
41
|
+
}
|
|
42
|
+
options.onProgress?.(total, total);
|
|
43
|
+
return last;
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"paths": "bun run scripts/paths.ts",
|
|
24
24
|
"verify": "bun run gate",
|
|
25
25
|
"prepublishOnly": "bun run gate --force",
|
|
26
|
-
"gate": "bun run scripts/paths.ts --only gate.ts"
|
|
26
|
+
"gate": "bun run scripts/paths.ts --only gate.ts",
|
|
27
|
+
"lint": "biome check --error-on-warnings ."
|
|
27
28
|
},
|
|
28
29
|
"files": [
|
|
29
30
|
"dist",
|
|
@@ -288,6 +289,12 @@
|
|
|
288
289
|
"source": "./src/core/passwordPolicy.ts",
|
|
289
290
|
"import": "./dist/core/passwordPolicy.js"
|
|
290
291
|
},
|
|
292
|
+
"./search-pattern": {
|
|
293
|
+
"types": "./dist/core/searchPattern.d.ts",
|
|
294
|
+
"bun": "./src/core/searchPattern.ts",
|
|
295
|
+
"source": "./src/core/searchPattern.ts",
|
|
296
|
+
"import": "./dist/core/searchPattern.js"
|
|
297
|
+
},
|
|
291
298
|
"./session/claims": {
|
|
292
299
|
"types": "./dist/core/session/claims.d.ts",
|
|
293
300
|
"bun": "./src/core/session/claims.ts",
|
|
@@ -387,19 +394,22 @@
|
|
|
387
394
|
"zod": "^4.4.0"
|
|
388
395
|
},
|
|
389
396
|
"devDependencies": {
|
|
390
|
-
"@biomejs/biome": "^2.5.
|
|
397
|
+
"@biomejs/biome": "^2.5.14",
|
|
398
|
+
"@happy-dom/global-registrator": "^20.10.6",
|
|
391
399
|
"@types/bun": "^1.3.14",
|
|
392
400
|
"@types/node": "^24",
|
|
401
|
+
"hono": "4.12.28",
|
|
393
402
|
"typescript": "^6.0.3",
|
|
394
|
-
"zod": "4.4.3"
|
|
395
|
-
"@happy-dom/global-registrator": "^20.10.6",
|
|
396
|
-
"hono": "4.12.28"
|
|
403
|
+
"zod": "4.4.3"
|
|
397
404
|
},
|
|
398
405
|
"gate": {
|
|
399
406
|
"stages": [
|
|
400
407
|
{
|
|
401
408
|
"script": "paths"
|
|
402
409
|
},
|
|
410
|
+
{
|
|
411
|
+
"script": "lint"
|
|
412
|
+
},
|
|
403
413
|
{
|
|
404
414
|
"script": "typecheck"
|
|
405
415
|
},
|
|
@@ -54,11 +54,13 @@ const { caches: _dropped, ...noCaches } = sw;
|
|
|
54
54
|
installShellWorker(noCaches, { cachePrefix: 'app-shell-', version: 'v1', precache: ['/'] });
|
|
55
55
|
`,
|
|
56
56
|
);
|
|
57
|
-
const child = Bun.
|
|
57
|
+
const child = Bun.spawn([process.execPath, `${REPO}/node_modules/typescript/bin/tsc`, '-p', root], {
|
|
58
|
+
stdin: 'ignore',
|
|
58
59
|
stdout: 'pipe',
|
|
59
60
|
stderr: 'pipe',
|
|
60
61
|
});
|
|
61
|
-
|
|
62
|
+
const [stdout, stderr] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
63
|
+
return `${stdout}${stderr}`;
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
const output = await typecheck();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The owner's own examples are the acceptance test — each one is a row below,
|
|
3
|
+
* against the titles he named.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'bun:test';
|
|
6
|
+
import {
|
|
7
|
+
createSearchMatcher,
|
|
8
|
+
matchesSearchPattern,
|
|
9
|
+
parseSearchPattern,
|
|
10
|
+
searchPatternToLike,
|
|
11
|
+
searchPatternToRegExp,
|
|
12
|
+
} from './searchPattern';
|
|
13
|
+
|
|
14
|
+
const TITLES = [
|
|
15
|
+
'The Hunt for Red October',
|
|
16
|
+
'The Mask',
|
|
17
|
+
'10 Cloverfield Lane',
|
|
18
|
+
'Cloverfield',
|
|
19
|
+
'The Agony and the Ecstasy',
|
|
20
|
+
'Other People',
|
|
21
|
+
'M*A*S*H',
|
|
22
|
+
'100% Wolf',
|
|
23
|
+
'Snake_Eyes',
|
|
24
|
+
];
|
|
25
|
+
const find = (raw: string, options = {}) => {
|
|
26
|
+
const { test } = createSearchMatcher(raw, options);
|
|
27
|
+
return TITLES.filter(test);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('the owner’s examples (flix, 2026-09-28)', () => {
|
|
31
|
+
it('"the*the" finds a title with `the` twice, and not one with it once', () => {
|
|
32
|
+
// The real title is "The Hunt for Red October" — ONE `the` — so it is
|
|
33
|
+
// correctly absent; the request remembered it as "…for the red October".
|
|
34
|
+
expect(find('the*the')).toEqual(['The Agony and the Ecstasy']);
|
|
35
|
+
expect(createSearchMatcher('the*the').test('The hunt for the red October')).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it('"hunt*red" finds The Hunt for Red October — contains, not starts-with', () => {
|
|
38
|
+
expect(find('hunt*red')).toEqual(['The Hunt for Red October']);
|
|
39
|
+
});
|
|
40
|
+
it('"*clov*" finds 10 Cloverfield Lane, exactly as "clov" does', () => {
|
|
41
|
+
expect(find('*clov*')).toEqual(['10 Cloverfield Lane', 'Cloverfield']);
|
|
42
|
+
expect(find('clov')).toEqual(find('*clov*'));
|
|
43
|
+
});
|
|
44
|
+
it('order matters: "red*hunt" does not find The Hunt for Red October', () => {
|
|
45
|
+
expect(find('red*hunt')).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
it('a star reaches INSIDE a word — "*over*" finds Cloverfield', () => {
|
|
48
|
+
expect(find('*over*')).toEqual(['10 Cloverfield Lane', 'Cloverfield']);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('parseSearchPattern', () => {
|
|
53
|
+
it('is empty for blank text and for stars alone', () => {
|
|
54
|
+
expect(parseSearchPattern(' ').kind).toBe('empty');
|
|
55
|
+
expect(parseSearchPattern('*').kind).toBe('empty');
|
|
56
|
+
expect(parseSearchPattern(' ** ').kind).toBe('empty');
|
|
57
|
+
});
|
|
58
|
+
it('is plain without a star, trimmed and whitespace-collapsed', () => {
|
|
59
|
+
expect(parseSearchPattern(' the office ')).toEqual({ kind: 'plain', raw: ' the office ', text: 'the office' });
|
|
60
|
+
});
|
|
61
|
+
it('splits on the star and keeps spaces as literal', () => {
|
|
62
|
+
const p = parseSearchPattern('the *the');
|
|
63
|
+
expect(p).toMatchObject({ kind: 'wildcard', parts: ['the ', 'the'] });
|
|
64
|
+
});
|
|
65
|
+
it('drops a whitespace-only part, which would otherwise demand two gaps', () => {
|
|
66
|
+
expect(parseSearchPattern('hunt * * red')).toMatchObject({ kind: 'wildcard', parts: ['hunt ', ' red'] });
|
|
67
|
+
expect(find('hunt * * red')).toEqual(['The Hunt for Red October']);
|
|
68
|
+
});
|
|
69
|
+
it('with wildcard OFF, a star is plain text', () => {
|
|
70
|
+
expect(parseSearchPattern('m*a*s*h', { wildcard: false })).toMatchObject({ kind: 'plain', text: 'm*a*s*h' });
|
|
71
|
+
expect(find('m*a*s*h', { wildcard: false })).toEqual(['M*A*S*H']);
|
|
72
|
+
});
|
|
73
|
+
it('regex is OFF by default — /…/ is plain text', () => {
|
|
74
|
+
expect(parseSearchPattern('/hunt.+red/').kind).toBe('plain');
|
|
75
|
+
expect(parseSearchPattern('/hunt.*red/').kind).toBe('wildcard');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('regex (opt-in)', () => {
|
|
80
|
+
const on = { regex: true };
|
|
81
|
+
it('compiles /source/flags, always case-insensitive, never stateful', () => {
|
|
82
|
+
expect(parseSearchPattern('/^the (mask|agony)/g', on)).toMatchObject({ kind: 'regex', source: '^the (mask|agony)', flags: 'i' });
|
|
83
|
+
expect(find('/^the (mask|agony)/g', on)).toEqual(['The Mask', 'The Agony and the Ecstasy']);
|
|
84
|
+
});
|
|
85
|
+
it('is invalid — and matches nothing — when it will not compile', () => {
|
|
86
|
+
const p = parseSearchPattern('/(unclosed/', on);
|
|
87
|
+
expect(p).toMatchObject({ kind: 'invalid', syntax: 'regex' });
|
|
88
|
+
expect(matchesSearchPattern(p, 'anything')).toBe(false);
|
|
89
|
+
expect(find('/(unclosed/', on)).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
it('names an unknown flag', () => {
|
|
92
|
+
expect(parseSearchPattern('/a/q', on)).toMatchObject({ kind: 'invalid', message: 'Unknown regex flag "q"' });
|
|
93
|
+
});
|
|
94
|
+
it('does not mistake a slash inside a title for a regex', () => {
|
|
95
|
+
expect(parseSearchPattern('AC/DC', on).kind).toBe('plain');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('searchPatternToRegExp', () => {
|
|
100
|
+
it('escapes regex metacharacters in plain and wildcard text', () => {
|
|
101
|
+
expect(find('100%')).toEqual(['100% Wolf']);
|
|
102
|
+
expect(searchPatternToRegExp(parseSearchPattern('a.b*(c)'))?.test('A.B then (C)')).toBe(true);
|
|
103
|
+
expect(searchPatternToRegExp(parseSearchPattern('a.b*(c)'))?.test('axb then (c)')).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
it('lets a gap cross a newline', () => {
|
|
106
|
+
expect(matchesSearchPattern(parseSearchPattern('first*second'), 'first line\nsecond line')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
it('is null for empty and invalid', () => {
|
|
109
|
+
expect(searchPatternToRegExp(parseSearchPattern(''))).toBeNull();
|
|
110
|
+
expect(searchPatternToRegExp(parseSearchPattern('/(/', { regex: true }))).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('searchPatternToLike', () => {
|
|
115
|
+
it('is a contains for plain text and an ordered contains for a wildcard', () => {
|
|
116
|
+
expect(searchPatternToLike(parseSearchPattern('the office'))).toBe('%the office%');
|
|
117
|
+
expect(searchPatternToLike(parseSearchPattern('the*the'))).toBe('%the%the%');
|
|
118
|
+
expect(searchPatternToLike(parseSearchPattern('*clov*'))).toBe('%clov%');
|
|
119
|
+
});
|
|
120
|
+
it('escapes LIKE’s own wildcards and the escape character itself', () => {
|
|
121
|
+
expect(searchPatternToLike(parseSearchPattern('100%*snake_eyes'))).toBe('%100\\%%snake\\_eyes%');
|
|
122
|
+
expect(searchPatternToLike(parseSearchPattern('a\\b'))).toBe('%a\\\\b%');
|
|
123
|
+
expect(searchPatternToLike(parseSearchPattern('50%'), '!')).toBe('%50!%%');
|
|
124
|
+
});
|
|
125
|
+
it('has no form for empty, regex or invalid — the caller decides', () => {
|
|
126
|
+
expect(searchPatternToLike(parseSearchPattern(''))).toBeNull();
|
|
127
|
+
expect(searchPatternToLike(parseSearchPattern('/a/', { regex: true }))).toBeNull();
|
|
128
|
+
expect(searchPatternToLike(parseSearchPattern('/(/', { regex: true }))).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a search box's text MEANS — plain words, a `*` wildcard, or a regular
|
|
3
|
+
* expression — decided once, in one place, for the browser and the server alike.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this exists (owner request via flix, 2026-09-28) ────────────────────
|
|
6
|
+
* *"type * to mean any character in the search. Then if I know two words in a
|
|
7
|
+
* movie but not what is between them I can put the * between them like
|
|
8
|
+
* "the*the" could pull up "The hunt for the red October" … but not … "The mask".
|
|
9
|
+
* Also "hunt*red" would find "The hunt for the red October".*
|
|
10
|
+
*
|
|
11
|
+
* A word index cannot answer that: it knows the title contains `the`, not that it
|
|
12
|
+
* contains it TWICE, and not in which order its words came. So a `*` turns the
|
|
13
|
+
* search into an ordered substring match — `the*the` is "`the`, then anything,
|
|
14
|
+
* then `the`" — and a search without one is untouched, because every app already
|
|
15
|
+
* has a plain search it has tuned (flix ranks by FTS5 + bm25; see its
|
|
16
|
+
* `src/server/library.ts`).
|
|
17
|
+
*
|
|
18
|
+
* ── The semantics, all of them ──────────────────────────────────────────────
|
|
19
|
+
* - **Contains, never anchored.** `hunt*red` matches *The Hunt for Red October*
|
|
20
|
+
* although the title does not START with `hunt`. So leading and trailing stars
|
|
21
|
+
* are no-ops: `*clov*` ≡ `clov*` ≡ `clov` in what they match — but only the
|
|
22
|
+
* starred forms are WILDCARD searches, which is what lets an app answer `*over*`
|
|
23
|
+
* with *Cloverfield* (a substring inside a word) where its word index could not.
|
|
24
|
+
* - **Literal between the stars.** Spaces included, so `the *the` asks for a
|
|
25
|
+
* `the` followed by a space — a power user's word boundary, and nobody else
|
|
26
|
+
* notices. A part that is ONLY whitespace (`hunt * * red`) is dropped, because
|
|
27
|
+
* it would demand two separate gaps and fail on titles it plainly describes.
|
|
28
|
+
* - **Case-insensitive, always** — in the matcher and the regex alike. A search
|
|
29
|
+
* box that cares about capitals reads as broken.
|
|
30
|
+
* - **Regex is opt-in** (`regex: true`), and only in the unmistakable `/…/flags`
|
|
31
|
+
* form, so `AC/DC` is never mistaken for one. Its matcher runs where the data is
|
|
32
|
+
* in memory; there is deliberately no SQL form (SQLite and D1 ship no REGEXP),
|
|
33
|
+
* and a server that ever evaluates one must bound it — a viewer-typed regex on a
|
|
34
|
+
* shared server is a denial-of-service vector.
|
|
35
|
+
*
|
|
36
|
+
* ── How an app uses it ──────────────────────────────────────────────────────
|
|
37
|
+
* {@link parseSearchPattern} is the whole decision; everything else derives from
|
|
38
|
+
* its result. An app that filters in memory calls {@link createSearchMatcher}. An
|
|
39
|
+
* app that queries SQL asks {@link searchPatternToLike} for a `LIKE` pattern, and
|
|
40
|
+
* keeps its own tuned path for `kind: 'plain'`. The React `SearchInput` in
|
|
41
|
+
* `cursedbelt/react` parses with the SAME function, so the badge it shows is the
|
|
42
|
+
* syntax the query actually ran in.
|
|
43
|
+
*
|
|
44
|
+
* Isomorphic and dependency-free — it imports nothing.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** A syntax an app can switch on beyond plain words. */
|
|
48
|
+
export type SearchSyntax = 'wildcard' | 'regex';
|
|
49
|
+
|
|
50
|
+
/** Which syntaxes a search box understands. Absent means the defaults below. */
|
|
51
|
+
export interface SearchSyntaxOptions {
|
|
52
|
+
/** `*` stands for any run of characters (including none). Default **on**. */
|
|
53
|
+
wildcard?: boolean;
|
|
54
|
+
/** `/source/flags` is a regular expression. Default **off**. */
|
|
55
|
+
regex?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_SEARCH_SYNTAX: Readonly<Required<SearchSyntaxOptions>> = Object.freeze({
|
|
59
|
+
wildcard: true,
|
|
60
|
+
regex: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** The wildcard character. One constant, so the help text can never disagree with the parser. */
|
|
64
|
+
export const SEARCH_WILDCARD = '*';
|
|
65
|
+
|
|
66
|
+
export type SearchPattern =
|
|
67
|
+
/** Nothing to search for — show everything. A lone `*` is this too. */
|
|
68
|
+
| { kind: 'empty'; raw: string }
|
|
69
|
+
/** Ordinary words; `text` is trimmed with runs of whitespace collapsed. */
|
|
70
|
+
| { kind: 'plain'; raw: string; text: string }
|
|
71
|
+
/** `parts` must appear in this order, anything between them. Never empty. */
|
|
72
|
+
| { kind: 'wildcard'; raw: string; text: string; parts: string[] }
|
|
73
|
+
| { kind: 'regex'; raw: string; source: string; flags: string }
|
|
74
|
+
/** The viewer's text uses a syntax but cannot be compiled — say why, match nothing. */
|
|
75
|
+
| { kind: 'invalid'; raw: string; syntax: SearchSyntax; message: string };
|
|
76
|
+
|
|
77
|
+
const REGEX_FORM = /^\/(.+)\/([a-z]*)$/;
|
|
78
|
+
/** Flags that change what matches. `g` and `y` are dropped: they make `test()` stateful. */
|
|
79
|
+
const REGEX_FLAGS = new Set(['i', 'm', 's', 'u', 'g', 'y']);
|
|
80
|
+
|
|
81
|
+
const collapse = (text: string): string => text.replace(/\s+/g, ' ');
|
|
82
|
+
|
|
83
|
+
/** Decide what a search box's text means under `options`. */
|
|
84
|
+
export function parseSearchPattern(raw: string, options: SearchSyntaxOptions = {}): SearchPattern {
|
|
85
|
+
const syntax = { ...DEFAULT_SEARCH_SYNTAX, ...options };
|
|
86
|
+
const text = collapse(raw.trim());
|
|
87
|
+
if (text.length === 0) return { kind: 'empty', raw };
|
|
88
|
+
|
|
89
|
+
if (syntax.regex) {
|
|
90
|
+
const form = REGEX_FORM.exec(text);
|
|
91
|
+
if (form) {
|
|
92
|
+
const [, source = '', given = ''] = form;
|
|
93
|
+
const unknown = [...given].find((f) => !REGEX_FLAGS.has(f));
|
|
94
|
+
if (unknown) {
|
|
95
|
+
return { kind: 'invalid', raw, syntax: 'regex', message: `Unknown regex flag "${unknown}"` };
|
|
96
|
+
}
|
|
97
|
+
const flags = [...new Set(['i', ...given])].filter((f) => f !== 'g' && f !== 'y').join('');
|
|
98
|
+
try {
|
|
99
|
+
new RegExp(source, flags);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
return { kind: 'invalid', raw, syntax: 'regex', message };
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'regex', raw, source, flags };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (syntax.wildcard && text.includes(SEARCH_WILDCARD)) {
|
|
109
|
+
const parts = text.split(SEARCH_WILDCARD).filter((part) => part.trim().length > 0);
|
|
110
|
+
if (parts.length === 0) return { kind: 'empty', raw };
|
|
111
|
+
return { kind: 'wildcard', raw, text, parts };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { kind: 'plain', raw, text };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The pattern as a `RegExp`, or `null` when it matches everything (`empty`) or
|
|
121
|
+
* nothing (`invalid`). Plain text becomes a case-insensitive contains.
|
|
122
|
+
*/
|
|
123
|
+
export function searchPatternToRegExp(pattern: SearchPattern): RegExp | null {
|
|
124
|
+
switch (pattern.kind) {
|
|
125
|
+
case 'empty':
|
|
126
|
+
case 'invalid':
|
|
127
|
+
return null;
|
|
128
|
+
case 'plain':
|
|
129
|
+
return new RegExp(escapeRegExp(pattern.text), 'i');
|
|
130
|
+
case 'wildcard':
|
|
131
|
+
// `[\s\S]*?` rather than `.*` so a gap may cross a newline in a
|
|
132
|
+
// multi-line field (a summary, a note) without needing the `s` flag.
|
|
133
|
+
return new RegExp(pattern.parts.map(escapeRegExp).join('[\\s\\S]*?'), 'i');
|
|
134
|
+
case 'regex':
|
|
135
|
+
return new RegExp(pattern.source, pattern.flags);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Does `text` match? `empty` matches everything; `invalid` matches nothing. */
|
|
140
|
+
export function matchesSearchPattern(pattern: SearchPattern, text: string): boolean {
|
|
141
|
+
if (pattern.kind === 'empty') return true;
|
|
142
|
+
return searchPatternToRegExp(pattern)?.test(text) ?? false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse once, compile once, then test many — the shape an in-memory list filter
|
|
147
|
+
* wants. `matcher(title)` for every row costs one regex test, not one parse.
|
|
148
|
+
*/
|
|
149
|
+
export function createSearchMatcher(
|
|
150
|
+
raw: string,
|
|
151
|
+
options: SearchSyntaxOptions = {},
|
|
152
|
+
): { pattern: SearchPattern; test: (text: string) => boolean } {
|
|
153
|
+
const pattern = parseSearchPattern(raw, options);
|
|
154
|
+
if (pattern.kind === 'empty') return { pattern, test: () => true };
|
|
155
|
+
const re = searchPatternToRegExp(pattern);
|
|
156
|
+
return { pattern, test: re ? (text) => re.test(text) : () => false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The pattern as a SQL `LIKE` pattern, escaped with `escape` — so the query must
|
|
161
|
+
* say `LIKE ? ESCAPE '\'` (or whatever `escape` is). `null` for `empty`, `regex`
|
|
162
|
+
* and `invalid`: none of those has a `LIKE` form, and the caller decides what
|
|
163
|
+
* each means rather than being handed a pattern that silently matches too much.
|
|
164
|
+
*
|
|
165
|
+
* Case is left as typed. SQLite's `LIKE` is already case-insensitive for ASCII;
|
|
166
|
+
* a database whose `LIKE` is not (Postgres) should use `ILIKE` with the same string.
|
|
167
|
+
*/
|
|
168
|
+
export function searchPatternToLike(pattern: SearchPattern, escapeChar = '\\'): string | null {
|
|
169
|
+
const literal = (text: string) => text.replace(/[%_]/g, (c) => `${escapeChar}${c}`);
|
|
170
|
+
const escapeAll = (text: string) => literal(text.split(escapeChar).join(`${escapeChar}${escapeChar}`));
|
|
171
|
+
switch (pattern.kind) {
|
|
172
|
+
case 'plain':
|
|
173
|
+
return `%${escapeAll(pattern.text)}%`;
|
|
174
|
+
case 'wildcard':
|
|
175
|
+
return `%${pattern.parts.map(escapeAll).join('%')}%`;
|
|
176
|
+
default:
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -22,9 +22,10 @@ export interface CtgrFetchRequest {
|
|
|
22
22
|
init: RequestInit;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
25
|
+
// 7 MB per-file cap for GET-only mode. A file rides as base64 inside JSON — a third
|
|
26
|
+
// larger — and both chunk stores refuse a payload over 10 MB, so a 10 MB file was let
|
|
27
|
+
// through here only to be refused by the server after its first chunk.
|
|
28
|
+
const CTGR_MAX_FILE_BYTES = 7 * 1024 * 1024;
|
|
28
29
|
|
|
29
30
|
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
30
31
|
|
|
@@ -73,13 +73,11 @@ export const installCtgrGuard = (): void => {
|
|
|
73
73
|
// ── XMLHttpRequest ───────────────────────────────────────────────────────────
|
|
74
74
|
if (typeof XMLHttpRequest !== 'undefined') {
|
|
75
75
|
const originalOpen = XMLHttpRequest.prototype.open;
|
|
76
|
-
// biome-ignore lint/suspicious/noExplicitAny: matching XHR.open's variadic overloads
|
|
77
76
|
XMLHttpRequest.prototype.open = function open(method: string, url: string, ...rest: any[]) {
|
|
78
77
|
const upper = String(method).toUpperCase();
|
|
79
78
|
if (isCtgrModeOn() && !SAFE_METHODS.has(upper) && isGuardedPath(String(url))) {
|
|
80
79
|
throw block();
|
|
81
80
|
}
|
|
82
|
-
// biome-ignore lint/suspicious/noExplicitAny: forwarding the original variadic call
|
|
83
81
|
return (originalOpen as any).call(this, method, url, ...rest);
|
|
84
82
|
};
|
|
85
83
|
}
|
package/src/ctgrClient/index.ts
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* GET-only client:
|
|
4
4
|
* • the per-device mode flag + the `?ctgr=t|f` boot toggle (`ctgrConfig`),
|
|
5
5
|
* • the request converter that rewrites a mutation into `/ctgr<xx>` GET twins,
|
|
6
|
+
* • the sender that puts those twins on the wire and answers with the one real response,
|
|
6
7
|
* • the network-boundary guard that blocks a stray raw verb.
|
|
7
8
|
*
|
|
8
9
|
* The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
|
|
9
10
|
* `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
|
|
10
11
|
* `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
|
|
11
|
-
* through `convertIfOnlyGETRequests`.
|
|
12
|
+
* through `convertIfOnlyGETRequests`, then `sendCtgrRequests`.
|
|
12
13
|
*/
|
|
13
14
|
export {
|
|
14
15
|
applyCtgrFromLocation,
|
|
@@ -19,3 +20,9 @@ export {
|
|
|
19
20
|
} from './ctgrConfig.js';
|
|
20
21
|
export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
|
|
21
22
|
export { installCtgrGuard } from './ctgrGuard.js';
|
|
23
|
+
export {
|
|
24
|
+
CTGR_INCOMPLETE_STATUS,
|
|
25
|
+
type CtgrSend,
|
|
26
|
+
type SendCtgrOptions,
|
|
27
|
+
sendCtgrRequests,
|
|
28
|
+
} from './sendRequests.js';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { ChunkStore } from '../ctgr/chunkStore.js';
|
|
3
|
+
import { type CtgrFetchRequest, convertIfOnlyGETRequests, sendCtgrRequests, setCtgrMode } from './index.js';
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
localStorage.clear();
|
|
7
|
+
setCtgrMode(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/** A multi-chunk upload: random bytes do not gzip, so 40 KB is a couple of dozen chunks. */
|
|
11
|
+
async function upload(): Promise<CtgrFetchRequest[]> {
|
|
12
|
+
const bytes = new Uint8Array(40_000);
|
|
13
|
+
crypto.getRandomValues(bytes);
|
|
14
|
+
const form = new FormData();
|
|
15
|
+
form.append('file', new File([bytes], 'shot.png', { type: 'image/png' }));
|
|
16
|
+
const { requests } = await convertIfOnlyGETRequests('/api/v1/library/assets', { method: 'POST', body: form });
|
|
17
|
+
expect(requests.length).toBeGreaterThan(10);
|
|
18
|
+
return requests;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The server half, as `mountCtgrTunnel` answers: 202 per piece, the real response at the end. */
|
|
22
|
+
function server(store = new ChunkStore()) {
|
|
23
|
+
let inFlight = 0;
|
|
24
|
+
let peak = 0;
|
|
25
|
+
const seen: number[] = [];
|
|
26
|
+
const send = async (request: CtgrFetchRequest): Promise<Response> => {
|
|
27
|
+
inFlight++;
|
|
28
|
+
peak = Math.max(peak, inFlight);
|
|
29
|
+
await Bun.sleep(Math.random() * 3);
|
|
30
|
+
const params = new URL(request.url, 'http://x').searchParams;
|
|
31
|
+
seen.push(Number(params.get('_ci')));
|
|
32
|
+
const result = await store.add(params);
|
|
33
|
+
inFlight--;
|
|
34
|
+
if (result === false) return Response.json({ acknowledged: true }, { status: 202 });
|
|
35
|
+
if (result instanceof Error) return Response.json({ error: result.message }, { status: 400 });
|
|
36
|
+
return Response.json({ id: 'asset-1', name: result.files?.[0]?.originalname }, { status: 201 });
|
|
37
|
+
};
|
|
38
|
+
return { send, seen, peak: () => peak };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('sendCtgrRequests', () => {
|
|
42
|
+
test('sends the pieces several at a time and answers with the real response', async () => {
|
|
43
|
+
const requests = await upload();
|
|
44
|
+
const { send, seen, peak } = server();
|
|
45
|
+
const progress: number[] = [];
|
|
46
|
+
const response = await sendCtgrRequests(requests, send, { onProgress: (sent) => progress.push(sent) });
|
|
47
|
+
expect(response.status).toBe(201);
|
|
48
|
+
expect(await response.json()).toEqual({ id: 'asset-1', name: 'shot.png' });
|
|
49
|
+
expect(peak()).toBe(4);
|
|
50
|
+
expect(seen.at(-1)).toBe(requests.length - 1);
|
|
51
|
+
expect(progress.at(-1)).toBe(requests.length);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('🔴 a piece the server lost is a 502, never the 202 `{acknowledged:true}` a caller would read as the result', async () => {
|
|
55
|
+
const requests = await upload();
|
|
56
|
+
const { send } = server();
|
|
57
|
+
const losing = (request: CtgrFetchRequest): Promise<Response> =>
|
|
58
|
+
new URL(request.url, 'http://x').searchParams.get('_ci') === '3'
|
|
59
|
+
? Promise.resolve(Response.json({ acknowledged: true }, { status: 202 }))
|
|
60
|
+
: send(request);
|
|
61
|
+
const response = await sendCtgrRequests(requests, losing);
|
|
62
|
+
expect(response.ok).toBe(false);
|
|
63
|
+
expect(response.status).toBe(502);
|
|
64
|
+
expect(((await response.json()) as { code: string }).code).toBe('CTGR_INCOMPLETE');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the first refusal stops the send and is the answer', async () => {
|
|
68
|
+
const requests = await upload();
|
|
69
|
+
let calls = 0;
|
|
70
|
+
const refusing = async (request: CtgrFetchRequest): Promise<Response> => {
|
|
71
|
+
calls++;
|
|
72
|
+
return new URL(request.url, 'http://x').searchParams.get('_ci') === '0'
|
|
73
|
+
? Response.json({ error: 'nope' }, { status: 503 })
|
|
74
|
+
: Response.json({ acknowledged: true }, { status: 202 });
|
|
75
|
+
};
|
|
76
|
+
const response = await sendCtgrRequests(requests, refusing, { concurrency: 1 });
|
|
77
|
+
expect(response.status).toBe(503);
|
|
78
|
+
expect(calls).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('one request is sent as it is', async () => {
|
|
82
|
+
const response = await sendCtgrRequests([{ url: '/api/v1/x', init: { method: 'GET' } }], async () => new Response('hi'));
|
|
83
|
+
expect(await response.text()).toBe('hi');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Send what `convertIfOnlyGETRequests` produced, and answer with the ONE response that
|
|
3
|
+
* stands for the original request.
|
|
4
|
+
*
|
|
5
|
+
* Every client used to write this as `for (const req of requests) res = await fetch(…)`,
|
|
6
|
+
* and that loop had two faults that together made a GET-only upload vanish (desk, task
|
|
7
|
+
* "component attachment not attached", 2026-10-01):
|
|
8
|
+
*
|
|
9
|
+
* 1. **A 202 read as success.** The server answers 202 `{acknowledged:true}` to every
|
|
10
|
+
* chunk until it holds them all. If one chunk is refused (a 400, a 503 from the chunk
|
|
11
|
+
* store) or a partial is lost (an evicted object, a deploy), the loop kept going and
|
|
12
|
+
* the LAST chunk answered 202 — `ok` — so the caller parsed `{acknowledged:true}` as
|
|
13
|
+
* the created thing. desk stored an attachment id of `undefined`: no error, no file.
|
|
14
|
+
* 2. **One round trip per 1.8 KB, in series.** A 2 MB screenshot is ~1,500 GETs; at a
|
|
15
|
+
* remote device's ~100 ms each that is minutes with nothing to show for it.
|
|
16
|
+
*
|
|
17
|
+
* So: every chunk but the last goes out `concurrency` at a time (both chunk stores accept
|
|
18
|
+
* chunks in any order — `ChunkStore` and the Worker's `CtgrChunkObject` file each by
|
|
19
|
+
* `_ci`), the first refusal stops the send and IS the answer, and the last chunk goes
|
|
20
|
+
* alone once the rest are acknowledged. A last chunk that still answers 202 means the
|
|
21
|
+
* server lost pieces, and that becomes a 502 the caller's ordinary error path reports.
|
|
22
|
+
*/
|
|
23
|
+
import type { CtgrFetchRequest } from './convertRequest.js';
|
|
24
|
+
|
|
25
|
+
/** How the caller puts one request on the wire — its own `fetch`, with its own headers. */
|
|
26
|
+
export type CtgrSend = (request: CtgrFetchRequest) => Promise<Response>;
|
|
27
|
+
|
|
28
|
+
export interface SendCtgrOptions {
|
|
29
|
+
/** Chunks in flight at once. Default 4 — fast, and gentle on an edge's rate limits. */
|
|
30
|
+
concurrency?: number;
|
|
31
|
+
/** Told after each chunk is acknowledged, for a progress bar: `(sent, total)`. */
|
|
32
|
+
onProgress?: (sent: number, total: number) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The status a last chunk is given when the server still answers "waiting for more". */
|
|
36
|
+
export const CTGR_INCOMPLETE_STATUS = 502;
|
|
37
|
+
|
|
38
|
+
export async function sendCtgrRequests(
|
|
39
|
+
requests: readonly CtgrFetchRequest[],
|
|
40
|
+
send: CtgrSend,
|
|
41
|
+
options: SendCtgrOptions = {},
|
|
42
|
+
): Promise<Response> {
|
|
43
|
+
const first = requests[0];
|
|
44
|
+
if (!first) throw new Error('ctgr: no request to send');
|
|
45
|
+
const total = requests.length;
|
|
46
|
+
if (total === 1) {
|
|
47
|
+
const only = await send(first);
|
|
48
|
+
options.onProgress?.(1, 1);
|
|
49
|
+
return only;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
|
|
53
|
+
const pieces = requests.slice(0, -1);
|
|
54
|
+
let next = 0;
|
|
55
|
+
let sent = 0;
|
|
56
|
+
let refusal: Response | null = null;
|
|
57
|
+
|
|
58
|
+
const worker = async (): Promise<void> => {
|
|
59
|
+
while (refusal === null && next < pieces.length) {
|
|
60
|
+
const piece = pieces[next++] as CtgrFetchRequest;
|
|
61
|
+
const response = await send(piece);
|
|
62
|
+
if (response.status !== 202 && !response.ok) {
|
|
63
|
+
refusal ??= response;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// An intermediate's body is `{acknowledged:true}`; read it so the connection frees.
|
|
67
|
+
await response.body?.cancel().catch(() => undefined);
|
|
68
|
+
options.onProgress?.(++sent, total);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, pieces.length) }, worker));
|
|
72
|
+
if (refusal) return refusal;
|
|
73
|
+
|
|
74
|
+
const last = await send(requests[total - 1] as CtgrFetchRequest);
|
|
75
|
+
if (last.status === 202) {
|
|
76
|
+
await last.body?.cancel().catch(() => undefined);
|
|
77
|
+
return Response.json(
|
|
78
|
+
{
|
|
79
|
+
error: 'The server did not receive every piece of this request — nothing was saved. Send it again.',
|
|
80
|
+
code: 'CTGR_INCOMPLETE',
|
|
81
|
+
},
|
|
82
|
+
{ status: CTGR_INCOMPLETE_STATUS },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
options.onProgress?.(total, total);
|
|
86
|
+
return last;
|
|
87
|
+
}
|
|
@@ -250,16 +250,20 @@ console.log(JSON.stringify(out));
|
|
|
250
250
|
`,
|
|
251
251
|
);
|
|
252
252
|
|
|
253
|
-
const child = Bun.
|
|
253
|
+
const child = Bun.spawn(['bun', 'run', 'entry.ts'], {
|
|
254
254
|
cwd: root,
|
|
255
|
+
stdin: 'ignore',
|
|
255
256
|
stdout: 'pipe',
|
|
256
257
|
stderr: 'pipe',
|
|
257
258
|
});
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
259
|
+
const [stdoutRaw, stderr, exitCode] = await Promise.all([
|
|
260
|
+
new Response(child.stdout).text(),
|
|
261
|
+
new Response(child.stderr).text(),
|
|
262
|
+
child.exited,
|
|
263
|
+
]);
|
|
264
|
+
const stdout = stdoutRaw.trim();
|
|
265
|
+
if (exitCode !== 0 || stdout === '') {
|
|
266
|
+
throw new Error(`the fixture could not run at all (exit ${exitCode}) — that is a broken check, not a passing one:\n${stderr}`);
|
|
263
267
|
}
|
|
264
268
|
return JSON.parse(stdout) as Record<string, Outcome>;
|
|
265
269
|
};
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
* to skip it. Ignored-vs-not is the property that actually regresses.
|
|
24
24
|
*/
|
|
25
25
|
import { describe, expect, test } from 'bun:test'
|
|
26
|
-
import { spawnSync } from 'node:child_process'
|
|
27
26
|
import { existsSync } from 'node:fs'
|
|
28
27
|
import { fileURLToPath } from 'node:url'
|
|
29
28
|
|
|
@@ -37,13 +36,18 @@ describe('bun.lock is part of the checkout', () => {
|
|
|
37
36
|
).toBe(true)
|
|
38
37
|
})
|
|
39
38
|
|
|
40
|
-
test('git is not told to ignore it', () => {
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
test('git is not told to ignore it', async () => {
|
|
40
|
+
let status: number
|
|
41
|
+
try {
|
|
42
|
+
const child = Bun.spawn(['git', 'check-ignore', '-q', 'bun.lock'], { cwd: repoRoot, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
|
|
43
|
+
status = await child.exited
|
|
44
|
+
} catch {
|
|
45
|
+
return // no git on this host: nothing to assert about ignore rules
|
|
46
|
+
}
|
|
43
47
|
|
|
44
48
|
// exit 0 = the path IS ignored; 1 = it is not; anything else = git could not tell us.
|
|
45
49
|
expect(
|
|
46
|
-
|
|
50
|
+
status,
|
|
47
51
|
'bun.lock is gitignored, so a fresh clone has no lockfile and `bun install --frozen-lockfile` cannot pass there. Delete the stanza from .gitignore rather than the assertion.',
|
|
48
52
|
).not.toBe(0)
|
|
49
53
|
})
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
* when `git` cannot answer — a check that silently degrades to green is worse than none.
|
|
26
26
|
*/
|
|
27
27
|
import { describe, expect, test } from 'bun:test';
|
|
28
|
-
import { spawnSync } from 'node:child_process';
|
|
29
28
|
import { existsSync } from 'node:fs';
|
|
30
29
|
import { fileURLToPath } from 'node:url';
|
|
31
30
|
import pkg from '../package.json';
|
|
@@ -36,14 +35,15 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
|
36
35
|
const shippedDirs = (): string[] =>
|
|
37
36
|
((pkg as { files?: string[] }).files ?? []).filter((f) => f !== 'dist' && existsSync(`${repoRoot}/${f}`));
|
|
38
37
|
|
|
39
|
-
const git = (...args: string[]): { ok: boolean; out: string } => {
|
|
40
|
-
const
|
|
41
|
-
|
|
38
|
+
const git = async (...args: string[]): Promise<{ ok: boolean; out: string }> => {
|
|
39
|
+
const child = Bun.spawn(['git', '-C', repoRoot, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
|
40
|
+
const [out, , code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
41
|
+
return { ok: code === 0, out: out.trim() };
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
describe('every file inside the published surface is tracked', () => {
|
|
45
|
-
test('git can answer at all — a degraded check must refuse, not pass', () => {
|
|
46
|
-
expect(git('rev-parse', '--is-inside-work-tree').ok, 'not a git checkout, so this check proves nothing').toBe(true);
|
|
45
|
+
test('git can answer at all — a degraded check must refuse, not pass', async () => {
|
|
46
|
+
expect((await git('rev-parse', '--is-inside-work-tree')).ok, 'not a git checkout, so this check proves nothing').toBe(true);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
test('package.json names a shippable directory to scan', () => {
|
|
@@ -52,10 +52,10 @@ describe('every file inside the published surface is tracked', () => {
|
|
|
52
52
|
expect(shippedDirs().length, '`files` names no committed directory — nothing would be scanned').toBeGreaterThan(0);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test('no untracked file sits inside a directory `files` ships', () => {
|
|
55
|
+
test('no untracked file sits inside a directory `files` ships', async () => {
|
|
56
56
|
// --others = untracked; --exclude-standard honours .gitignore, so deliberately
|
|
57
57
|
// ignored paths (.agent.noindex, node_modules) are not offences.
|
|
58
|
-
const { ok, out } = git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
|
|
58
|
+
const { ok, out } = await git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
|
|
59
59
|
expect(ok, 'git ls-files failed').toBe(true);
|
|
60
60
|
const untracked = out === '' ? [] : out.split('\n');
|
|
61
61
|
expect(
|