@rebasepro/utils 0.17.3 → 0.18.1

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.
@@ -1,65 +0,0 @@
1
- import type { SecurityOperation, SecurityRule } from "@rebasepro/types";
2
- import { sha1Hex } from "./sha1";
3
-
4
- /**
5
- * Naming of the Postgres policies generated from a collection's security rules.
6
- *
7
- * A rule without an explicit `name` is compiled to `<table>_<op>_<hash>`, where
8
- * the hash covers the rule's semantics. The Studio needs the same names to tell
9
- * "this policy came from your code" apart from "someone wrote this in SQL" —
10
- * without them it treats generated policies as foreign and offers to import
11
- * them back into the codebase they came from.
12
- *
13
- * This is the single definition of that naming. The DDL and Drizzle generators
14
- * both derive names from here, so a change cannot silently rename every policy
15
- * in every deployed database while the UI keeps matching the old ones.
16
- */
17
-
18
- /** Stable digest of the parts of a rule that determine what the policy does. */
19
- export function getPolicyNameHash(rule: SecurityRule): string {
20
- const data = JSON.stringify({
21
- a: rule.access,
22
- m: rule.mode,
23
- op: rule.operation,
24
- ops: rule.operations?.slice().sort(),
25
- own: rule.ownerField,
26
- rol: rule.roles?.slice().sort(),
27
- pg: rule.pgRoles?.slice().sort(),
28
- u: rule.using,
29
- w: rule.withCheck,
30
- c: rule.condition,
31
- ch: rule.check
32
- });
33
- return sha1Hex(data).substring(0, 7);
34
- }
35
-
36
- /** The operations a rule expands to — `operations` wins over `operation`. */
37
- export function getPolicyOperations(rule: SecurityRule): readonly SecurityOperation[] {
38
- return rule.operations && rule.operations.length > 0
39
- ? rule.operations
40
- : [rule.operation ?? "all"];
41
- }
42
-
43
- /**
44
- * Every Postgres policy name a single rule compiles to — one per operation.
45
- *
46
- * @param rule The security rule as written in the collection config.
47
- * @param tableName The rule's table (see `getTableName` in `@rebasepro/common`).
48
- */
49
- export function getPolicyNamesForRule(rule: SecurityRule, tableName: string): string[] {
50
- const ops = getPolicyOperations(rule);
51
- const ruleHash = getPolicyNameHash(rule);
52
-
53
- return ops.map((op, opIdx) => rule.name
54
- ? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
55
- : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`);
56
- }
57
-
58
- /** Every policy name a set of rules compiles to, for membership checks. */
59
- export function getPolicyNamesForRules(rules: SecurityRule[], tableName: string): Set<string> {
60
- const names = new Set<string>();
61
- for (const rule of rules) {
62
- for (const name of getPolicyNamesForRule(rule, tableName)) names.add(name);
63
- }
64
- return names;
65
- }
package/src/regexp.ts DELETED
@@ -1,41 +0,0 @@
1
- export function serializeRegExp(input: RegExp): string {
2
- if (!input) return "";
3
- // const fragments = input.toString().match(/\/(.*?)\/([a-z]*)?$/i);
4
- // if (fragments) {
5
- // if (fragments[2])
6
- // return input.toString();
7
- // return fragments[1];
8
- // }
9
- return input.toString();
10
- }
11
-
12
- /**
13
- * Get a RegExp out of a serialized string
14
- * @param input
15
- */
16
- export function hydrateRegExp(input?: string): RegExp | undefined {
17
- if (!input) return undefined;
18
- const fragments = input.match(/\/(.*?)\/([a-z]*)?$/i);
19
- if (fragments) {
20
- return new RegExp(fragments[1], fragments[2] || "");
21
- } else {
22
- return new RegExp(input, "");
23
- }
24
- }
25
-
26
- /**
27
- * Is `input` something {@link hydrateRegExp} can turn into a working RegExp?
28
- *
29
- * This used to pattern-match the *shape* of a regex literal and, failing that,
30
- * fall back to "does it contain any regex-ish character" — which said yes to
31
- * malformed input like `/[a-z/g`. The only answer that matters to a caller is
32
- * whether hydration succeeds, so ask the engine instead of approximating it.
33
- */
34
- export function isValidRegExp(input: string): boolean {
35
- if (!input) return false;
36
- try {
37
- return hydrateRegExp(input) !== undefined;
38
- } catch {
39
- return false;
40
- }
41
- }
package/src/sha1.ts DELETED
@@ -1,98 +0,0 @@
1
- /**
2
- * Minimal SHA-1 implementation that runs in both Node and the browser.
3
- *
4
- * This exists because generated Postgres policy names embed a SHA-1 digest of
5
- * the security rule. The DDL generator runs on the server (where `node:crypto`
6
- * is available) but the Studio has to derive the same names in the browser to
7
- * tell a policy it generated apart from one it did not. `node:crypto` cannot be
8
- * bundled for the browser, so the shared derivation needs a portable digest.
9
- *
10
- * SHA-1 is used purely to name things deterministically — never for security.
11
- * The output is byte-identical to `createHash("sha1").update(str).digest("hex")`,
12
- * which `sha1.test.ts` pins against `node:crypto` directly.
13
- */
14
-
15
- /** Rotate a 32-bit word left by `n` bits. */
16
- function rotl(value: number, n: number): number {
17
- return (value << n) | (value >>> (32 - n));
18
- }
19
-
20
- /**
21
- * SHA-1 digest of a string, hex-encoded.
22
- *
23
- * The input is encoded as UTF-8, matching Node's default handling of strings
24
- * passed to `hash.update(str)`.
25
- */
26
- export function sha1Hex(input: string): string {
27
- const bytes: number[] = Array.from(new TextEncoder().encode(input));
28
- const bitLength = bytes.length * 8;
29
-
30
- // Padding: 0x80, then zeroes up to 56 bytes mod 64, then the length as a
31
- // 64-bit big-endian integer.
32
- bytes.push(0x80);
33
- while (bytes.length % 64 !== 56) bytes.push(0);
34
-
35
- const hi = Math.floor(bitLength / 0x100000000);
36
- const lo = bitLength >>> 0;
37
- bytes.push((hi >>> 24) & 0xff, (hi >>> 16) & 0xff, (hi >>> 8) & 0xff, hi & 0xff);
38
- bytes.push((lo >>> 24) & 0xff, (lo >>> 16) & 0xff, (lo >>> 8) & 0xff, lo & 0xff);
39
-
40
- let h0 = 0x67452301;
41
- let h1 = 0xefcdab89;
42
- let h2 = 0x98badcfe;
43
- let h3 = 0x10325476;
44
- let h4 = 0xc3d2e1f0;
45
-
46
- const w = new Array<number>(80);
47
-
48
- for (let offset = 0; offset < bytes.length; offset += 64) {
49
- for (let i = 0; i < 16; i++) {
50
- const j = offset + i * 4;
51
- w[i] = ((bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3]) | 0;
52
- }
53
- for (let i = 16; i < 80; i++) {
54
- w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
55
- }
56
-
57
- let a = h0;
58
- let b = h1;
59
- let c = h2;
60
- let d = h3;
61
- let e = h4;
62
-
63
- for (let i = 0; i < 80; i++) {
64
- let f: number;
65
- let k: number;
66
- if (i < 20) {
67
- f = (b & c) | (~b & d);
68
- k = 0x5a827999;
69
- } else if (i < 40) {
70
- f = b ^ c ^ d;
71
- k = 0x6ed9eba1;
72
- } else if (i < 60) {
73
- f = (b & c) | (b & d) | (c & d);
74
- k = 0x8f1bbcdc;
75
- } else {
76
- f = b ^ c ^ d;
77
- k = 0xca62c1d6;
78
- }
79
-
80
- const temp = (rotl(a, 5) + f + e + k + w[i]) | 0;
81
- e = d;
82
- d = c;
83
- c = rotl(b, 30);
84
- b = a;
85
- a = temp;
86
- }
87
-
88
- h0 = (h0 + a) | 0;
89
- h1 = (h1 + b) | 0;
90
- h2 = (h2 + c) | 0;
91
- h3 = (h3 + d) | 0;
92
- h4 = (h4 + e) | 0;
93
- }
94
-
95
- return [h0, h1, h2, h3, h4]
96
- .map(word => (word >>> 0).toString(16).padStart(8, "0"))
97
- .join("");
98
- }
package/src/storage.ts DELETED
@@ -1,148 +0,0 @@
1
- /**
2
- * Reading and writing the small amounts of JSON a UI keeps between sessions —
3
- * open tabs, column widths, collapsed groups, recent searches.
4
- *
5
- * Every one of those reads is a read of *aged* state: it was written by whatever
6
- * version of the app the user last ran, and it is parsed by this one. The same
7
- * class the database upgrade path is careful about, in a place nothing migrates.
8
- *
9
- * A hand-rolled `JSON.parse(localStorage.getItem(key)!)` has four ways to throw
10
- * and no way to recover from any of them:
11
- *
12
- * - `localStorage` itself throws on access when storage is disabled (Safari
13
- * private browsing, blocked third-party cookies) or absent (SSR, Node).
14
- * - the stored text is not JSON, because a write was interrupted or a user
15
- * edited it.
16
- * - the stored text is valid JSON of the *wrong shape*, because an older
17
- * release wrote an object where this one expects an array. `parsed.map` is
18
- * then not a function.
19
- * - `setItem` throws `QuotaExceededError` once the origin's few megabytes are
20
- * full, which a view that persists query text on every edit will reach.
21
- *
22
- * When any of those happens inside a `useState` initializer it throws during
23
- * render, and the bad value is still there on reload, so the view is bricked
24
- * until someone opens devtools. These helpers turn all four into the fallback.
25
- */
26
-
27
- export interface WebStorageLike {
28
- getItem(key: string): string | null;
29
- setItem(key: string, value: string): void;
30
- removeItem(key: string): void;
31
- }
32
-
33
- /**
34
- * The ambient `localStorage`, or `null` where there is not one. Access itself
35
- * is what throws when storage is disabled, so even reaching for it is guarded.
36
- */
37
- export function getWebStorage(): WebStorageLike | null {
38
- try {
39
- const storage = (globalThis as { localStorage?: WebStorageLike }).localStorage;
40
- return storage ?? null;
41
- } catch {
42
- return null;
43
- }
44
- }
45
-
46
- export type ReadStoredJsonOptions<T> = {
47
- /** Returned whenever the stored value is missing, unreadable or rejected. */
48
- fallback: T;
49
- /**
50
- * Whether the parsed value is the shape this caller expects. Pass it
51
- * whenever the fallback is an array or a keyed object: valid JSON of the
52
- * wrong shape is the failure an upgrade actually produces, and it survives
53
- * `JSON.parse` untouched to fail later at the first `.map` or `.find`.
54
- */
55
- accept?: (value: unknown) => boolean;
56
- /** Defaults to the ambient `localStorage`. */
57
- storage?: WebStorageLike | null;
58
- };
59
-
60
- /**
61
- * Reads and parses a JSON value a previous session stored, falling back rather
62
- * than throwing. See the module comment for what it is falling back from.
63
- *
64
- * A rejected value is deliberately left in place rather than cleared: this
65
- * version not understanding it is not evidence that nothing does.
66
- */
67
- export function readStoredJson<T>(key: string, options: ReadStoredJsonOptions<T>): T {
68
- const storage = options.storage === undefined ? getWebStorage() : options.storage;
69
- if (!storage) return options.fallback;
70
-
71
- let raw: string | null;
72
- try {
73
- raw = storage.getItem(key);
74
- } catch {
75
- return options.fallback;
76
- }
77
- if (raw === null || raw === "") return options.fallback;
78
-
79
- let parsed: unknown;
80
- try {
81
- parsed = JSON.parse(raw);
82
- } catch {
83
- return options.fallback;
84
- }
85
-
86
- if (options.accept && !options.accept(parsed)) return options.fallback;
87
- return parsed as T;
88
- }
89
-
90
- /**
91
- * Persists a value as JSON. Returns whether it was stored, so a caller that
92
- * cares can say so — most do not, and for them the point is simply that a full
93
- * quota does not throw out of the effect doing the writing.
94
- */
95
- export function writeStoredJson(
96
- key: string,
97
- value: unknown,
98
- options: { storage?: WebStorageLike | null } = {}
99
- ): boolean {
100
- const storage = options.storage === undefined ? getWebStorage() : options.storage;
101
- if (!storage) return false;
102
- try {
103
- storage.setItem(key, JSON.stringify(value));
104
- return true;
105
- } catch {
106
- return false;
107
- }
108
- }
109
-
110
- /**
111
- * Persists an already-serialised string, for the values kept as plain text
112
- * rather than JSON — a selected id, a pane size.
113
- */
114
- export function writeStoredString(
115
- key: string,
116
- value: string,
117
- options: { storage?: WebStorageLike | null } = {}
118
- ): boolean {
119
- const storage = options.storage === undefined ? getWebStorage() : options.storage;
120
- if (!storage) return false;
121
- try {
122
- storage.setItem(key, value);
123
- return true;
124
- } catch {
125
- return false;
126
- }
127
- }
128
-
129
- /** Reads a plain string, absent rather than throwing where there is no storage. */
130
- export function readStoredString(
131
- key: string,
132
- options: { storage?: WebStorageLike | null } = {}
133
- ): string | null {
134
- const storage = options.storage === undefined ? getWebStorage() : options.storage;
135
- if (!storage) return null;
136
- try {
137
- return storage.getItem(key);
138
- } catch {
139
- return null;
140
- }
141
- }
142
-
143
- /** `accept` for a caller whose fallback is an array. */
144
- export const isArrayValue = (value: unknown): boolean => Array.isArray(value);
145
-
146
- /** `accept` for a caller whose fallback is a keyed object — and not an array. */
147
- export const isRecordValue = (value: unknown): boolean =>
148
- typeof value === "object" && value !== null && !Array.isArray(value);
package/src/strings.ts DELETED
@@ -1,117 +0,0 @@
1
- const tokenizeRegex = /[A-Z]{2,}(?=[A-Z][a-z]|\b)|[A-Z]?[a-z]+|[0-9]+(?:[a-z](?![a-z]))?|[A-Z]/g;
2
-
3
- export const toKebabCase = (str?: string) => {
4
- if (!str || typeof str !== "string") return "";
5
- const regExpMatchArray = str.match(tokenizeRegex);
6
- if (!regExpMatchArray) return "";
7
- return regExpMatchArray
8
- .map(x => x.toLowerCase())
9
- .join("-");
10
- };
11
-
12
- const snakeCaseRegex = tokenizeRegex;
13
-
14
- export const toSnakeCase = (str?: string) => {
15
- if (!str || typeof str !== "string") return "";
16
- const regExpMatchArray = str.match(snakeCaseRegex);
17
- if (!regExpMatchArray) return "";
18
- return regExpMatchArray
19
- .map(x => x.toLowerCase())
20
- .join("_");
21
- };
22
-
23
- export function camelCase(str: string): string {
24
- if (!str) return "";
25
- if (str.length === 1) return str.toLowerCase();
26
-
27
- // Split by hyphens, underscores, or spaces and filter out empty strings
28
- const parts = str.split(/[-_ ]+/).filter(Boolean);
29
-
30
- if (parts.length === 0) return "";
31
-
32
- // Start with first part in lowercase
33
- return parts[0].toLowerCase() +
34
- // Transform remaining parts to have first letter uppercase
35
- parts.slice(1)
36
- .map(part => part.charAt(0).toUpperCase() + part.substring(1).toLowerCase())
37
- .join("");
38
- }
39
-
40
- /**
41
- * A random base-36 string of exactly `strLength` characters.
42
- *
43
- * Not `Math.random().toString(36).slice(2, 2 + strLength)`: that has no
44
- * guaranteed length. Base-36 of a double drops trailing zeros, so the source
45
- * string is short about once in 36 calls and the slice quietly returns fewer
46
- * characters than asked for — `randomString(10)` returning 9. These values
47
- * prefix uploaded filenames to keep them apart, so a short one is a likelier
48
- * collision, and it fails at the rate that makes a test look flaky.
49
- */
50
- export function randomString(strLength = 5) {
51
- const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
52
- let result = "";
53
- for (let i = 0; i < strLength; i++) {
54
- result += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
55
- }
56
- return result;
57
- }
58
-
59
- export function randomColor() {
60
- return Math.floor(Math.random() * 16777215).toString(16);
61
- }
62
-
63
- export function slugify(text?: string, separator = "_", lowercase = true) {
64
- if (!text) return "";
65
- const from = "ãàáäâẽèéëêìíïîõòóöôùúüûñç·/_,:;-"
66
- const to = `aaaaaeeeeeiiiiooooouuuunc${separator}${separator}${separator}${separator}${separator}${separator}${separator}`;
67
-
68
- for (let i = 0, l = from.length; i < l; i++) {
69
- text = text.replace(new RegExp(from.charAt(i), "g"), to.charAt(i));
70
- }
71
-
72
- text = text
73
- .toString() // Cast to string
74
- .trim() // Remove whitespace from both sides of a string
75
- .replace(/^\s+|\s+$/g, "")
76
- .replace(/\s+/g, separator) // Replace spaces with separator
77
- .replace(/&/g, separator) // Replace & with separator
78
- .replace(/[^\w\\-]+/g, "") // Remove all non-word chars
79
- .replace(new RegExp("\\" + separator + "\\" + separator + "+", "g"),
80
- separator); // Replace multiple separators with single one
81
-
82
- return lowercase
83
- ? text.toLowerCase() // Convert the string to lowercase letters
84
- : text;
85
- }
86
-
87
- export function unslugify(slug?: string): string {
88
- if (!slug) return "";
89
- if (slug.includes("-") || slug.includes("_") || !slug.includes(" ")) {
90
- const result = slug.replace(/[-_]/g, " ");
91
- return result.replace(/\w\S*/g, function (txt) {
92
- return txt.charAt(0).toUpperCase() + txt.substring(1);
93
- }).trim();
94
- } else {
95
- return slug.trim();
96
- }
97
- }
98
-
99
- export function prettifyIdentifier(input: string) {
100
- if (!input) return "";
101
-
102
- let text = input;
103
-
104
- // 1. Handle camelCase and Acronyms
105
- // Group 1 ($1 $2): Lowercase followed by Uppercase (e.g., imageURL -> image URL)
106
- // Group 2 ($3 $4): Uppercase followed by Uppercase+lowercase (e.g., XMLParser -> XML Parser)
107
- text = text.replace(/([a-z])([A-Z])|([A-Z])([A-Z][a-z])/g, "$1$3 $2$4");
108
-
109
- // 2. Replace hyphens/underscores with spaces
110
- text = text.replace(/[_-]+/g, " ");
111
-
112
- // 3. Capitalize first letter of each word (Title Case)
113
- const s = text
114
- .trim()
115
- .replace(/\b\w/g, (char) => char.toUpperCase());
116
- return s;
117
- }