@rebasepro/utils 0.17.3-canary.gdd23447 → 0.18.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/src/dates.ts DELETED
@@ -1,69 +0,0 @@
1
- export const defaultDateFormat = "MMMM dd, yyyy, HH:mm:ss";
2
-
3
- /** Seven days, the distance past which a relative phrase stops being useful. */
4
- const DEFAULT_MAX_MS = 7 * 24 * 60 * 60 * 1000;
5
-
6
- export type FormatRelativeTimeOptions = {
7
- /**
8
- * The instant the distance is measured from. Defaults to the current time.
9
- * Pass it explicitly to make a caller testable without faking the clock.
10
- */
11
- now?: Date | number;
12
- /**
13
- * How far a value may sit from {@link now} and still be described
14
- * relatively. Beyond it the function returns `null` and the caller renders
15
- * an absolute date instead. Defaults to seven days.
16
- */
17
- maxMs?: number;
18
- };
19
-
20
- function toTime(value: Date | string | number | null | undefined): number | null {
21
- if (value === null || value === undefined || value === "") return null;
22
- const time = value instanceof Date ? value.getTime() : new Date(value).getTime();
23
- return Number.isNaN(time) ? null : time;
24
- }
25
-
26
- /**
27
- * Describes an instant relative to another one — "5m ago", "in 3h".
28
- *
29
- * The direction is part of the answer. Every hand-rolled version of this in the
30
- * codebase computed `now - then` and then tested only the positive side, so a
31
- * timestamp in the future fell through to whichever branch happened to be
32
- * first: a date scheduled for next month read "Just now", and one a couple of
33
- * hours out read "-1d ago". Both are dates a CMS holds all the time — a publish
34
- * date, a due date, an expiry — and neither shape can occur here, because the
35
- * distance is measured with {@link Math.abs} and the tense is chosen from the
36
- * sign rather than assumed.
37
- *
38
- * Returns `null` when the value is unreadable, or when it is further than
39
- * {@link FormatRelativeTimeOptions.maxMs} away in either direction. `null` is
40
- * "say it another way", not an error: the caller owns the absolute format, and
41
- * the locale and precision that go with it.
42
- */
43
- export function formatRelativeTime(
44
- value: Date | string | number | null | undefined,
45
- options: FormatRelativeTimeOptions = {}
46
- ): string | null {
47
- const then = toTime(value);
48
- if (then === null) return null;
49
-
50
- const now = options.now instanceof Date ? options.now.getTime() : (options.now ?? Date.now());
51
- const maxMs = options.maxMs ?? DEFAULT_MAX_MS;
52
-
53
- // Positive is the past, which is the only case the callers used to handle.
54
- const delta = now - then;
55
- const distance = Math.abs(delta);
56
- if (distance > maxMs) return null;
57
-
58
- const future = delta < 0;
59
-
60
- const minutes = Math.floor(distance / 60_000);
61
- if (minutes < 1) return future ? "in a moment" : "just now";
62
- if (minutes < 60) return future ? `in ${minutes}m` : `${minutes}m ago`;
63
-
64
- const hours = Math.floor(distance / 3_600_000);
65
- if (hours < 24) return future ? `in ${hours}h` : `${hours}h ago`;
66
-
67
- const days = Math.floor(distance / 86_400_000);
68
- return future ? `in ${days}d` : `${days}d ago`;
69
- }
package/src/fields.ts DELETED
@@ -1,27 +0,0 @@
1
-
2
-
3
- export function isDefaultFieldConfigId(id: string): boolean {
4
- return ["text_field",
5
- "multiline",
6
- "markdown",
7
- "url",
8
- "email",
9
- "switch",
10
- "select",
11
- "multi_select",
12
- "number_input",
13
- "number_select",
14
- "multi_number_select",
15
- "file_upload",
16
- "multi_file_upload",
17
- "reference",
18
- "multi_references",
19
- "relation",
20
- "date_time",
21
- "group",
22
- "key_value",
23
- "repeat",
24
- "custom_array",
25
- "block"
26
- ].includes(id);
27
- }
@@ -1,49 +0,0 @@
1
- export function flattenObject(obj: Record<string, unknown>, parentKey = "") {
2
- if (!obj) return obj;
3
- return Object.keys(obj).reduce((flatObj, key) => {
4
- const newKey = parentKey ? `${parentKey}.${key}` : key;
5
-
6
- if (typeof obj[key] === "object" && obj[key] !== null) {
7
- if (Array.isArray(obj[key])) {
8
- obj[key].forEach((item: unknown, index: number) => {
9
- if (typeof item === "object" && item !== null) {
10
- Object.assign(flatObj, flattenObject(item as Record<string, unknown>, `${newKey}[${index}]`));
11
- } else {
12
- flatObj[`${newKey}[${index}]`] = item;
13
- }
14
- });
15
- } else {
16
- Object.assign(flatObj, flattenObject(obj[key] as Record<string, unknown>, newKey));
17
- }
18
- } else {
19
- flatObj[newKey] = obj[key];
20
- }
21
-
22
- return flatObj;
23
- }, {} as { [key: string]: unknown });
24
- }
25
-
26
-
27
- // map from nested property key like "a.b.c" to the maximum array count found in a list of objects for that array
28
- export type ArrayValuesCount = Record<string, number>;
29
-
30
- export function getArrayValuesCount(array: Record<string, unknown>[]): ArrayValuesCount {
31
- return array.reduce((acc: ArrayValuesCount, obj: Record<string, unknown>) => {
32
- Object.entries(obj).forEach(([key, value]) => {
33
- // proceed only if value is an array
34
- if (Array.isArray(value)) {
35
- acc[key] = Math.max(acc[key] || 0, value.length);
36
- }
37
-
38
- // handle nested object
39
- if (typeof value === "object" && value !== null) {
40
- const nested = getArrayValuesCount([value as Record<string, unknown>]);
41
- Object.entries(nested).forEach(([nestedKey, nestedCount]) => {
42
- const compoundKey = `${key}.${nestedKey}`;
43
- acc[compoundKey] = Math.max(acc[compoundKey] || 0, nestedCount);
44
- });
45
- }
46
- });
47
- return acc;
48
- }, {});
49
- }
package/src/hash.ts DELETED
@@ -1,12 +0,0 @@
1
- export function hashString(str: string): number {
2
- if (!str) return 0;
3
- let hash = 0;
4
- let i;
5
- let chr;
6
- for (i = 0; i < str.length; i++) {
7
- chr = str.charCodeAt(i);
8
- hash = ((hash << 5) - hash) + chr;
9
- hash |= 0; // Convert to 32bit integer
10
- }
11
- return Math.abs(hash);
12
- }
package/src/index.ts DELETED
@@ -1,13 +0,0 @@
1
- export * from "./strings";
2
- export * from "./objects";
3
- export * from "./arrays";
4
- export * from "./dates";
5
- export * from "./storage";
6
- export * from "./hash";
7
- export * from "./sha1";
8
- export * from "./policy-names";
9
- export * from "./regexp";
10
- export * from "./flatten_object";
11
- export * from "./plurals";
12
- export * from "./names";
13
- export * from "./fields";
package/src/names.ts DELETED
@@ -1,186 +0,0 @@
1
- import { singular } from "./plurals";
2
- import { toSnakeCase } from "./strings";
3
-
4
- /**
5
- * Generates a foreign key column name from a given string, typically a collection slug or name.
6
- * It singularizes the name, converts it to snake_case and appends '_id'.
7
- *
8
- * Singularization runs *before* snake-casing so that acronyms survive: `toSnakeCase`
9
- * splits on every capital, which turned "URLs" into "ur_ls" and then "ur_l_id".
10
- *
11
- * @param name The base name to convert to a foreign key.
12
- * @returns A foreign key name in the format 'singular_name_id'.
13
- *
14
- * @example
15
- * // returns "user_id"
16
- * generateForeignKeyName("users")
17
- *
18
- * @example
19
- * // returns "category_id"
20
- * generateForeignKeyName("categories")
21
- *
22
- * @example
23
- * // returns "product_id"
24
- * generateForeignKeyName("Product")
25
- *
26
- */
27
- export function generateForeignKeyName(name: string): string {
28
- return `${toSnakeCase(singularizeForKey(name))}_id`;
29
- }
30
-
31
- /**
32
- * `singular()` handles real English plurals, but its final catch-all rule strips
33
- * any trailing "s", which mangles words that only look plural. Guard the two
34
- * cases that produce a column name nobody would recognise:
35
- *
36
- * - a double "s" ending is never a plural marker ("address", "class", "process"),
37
- * so stripping it yields "addres";
38
- * - a name that singularizes to nothing (the literal "s") would yield "_id".
39
- */
40
- function singularizeForKey(name: string): string {
41
- if (/ss$/i.test(name)) return name;
42
- const result = singular(name);
43
- return result.length > 0 ? result : name;
44
- }
45
-
46
- /**
47
- * What `generateForeignKeyName` returned before it learned to singularize:
48
- * snake-case the name, then chop one trailing "s".
49
- *
50
- * This is here to be *detected*, never to be generated. A database provisioned
51
- * under the old rule carries `categorie_id`, `addresse_id`, `children_id` or
52
- * `ur_l_id` where the current rule expects `category_id`, `address_id`,
53
- * `child_id` and `url_id` — and the boot-time schema ensure is additive, so it
54
- * would create the new column empty beside the populated old one and leave the
55
- * relation reading nothing. No error, no missing table: the failure is silent,
56
- * which is the only reason this function still exists.
57
- *
58
- * `ensureCollectionSchema` calls it to recognise that shape and say so.
59
- * Returns the same string as `generateForeignKeyName` for every regular plural,
60
- * so a caller can compare the two and act only when they differ.
61
- */
62
- export function legacyForeignKeyName(name: string): string {
63
- const snake = toSnakeCase(name);
64
- return `${snake.endsWith("s") ? snake.slice(0, -1) : snake}_id`;
65
- }
66
-
67
- /**
68
- * Truncate an identifier to what Postgres will actually store.
69
- *
70
- * Postgres silently truncates identifiers at NAMEDATALEN-1 = 63 **bytes**, so a
71
- * name generated longer than that is not the name the database ends up holding.
72
- * Anything that later looks the object up by the name it generated then misses.
73
- *
74
- * Byte length, not string length: NAMEDATALEN is a byte bound, and a multi-byte
75
- * character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
76
- *
77
- * `TextEncoder` rather than `Buffer`, which is not a matter of taste: `Buffer`
78
- * is a Node global, and this package is imported by browser-facing ones. It
79
- * typechecked only where `@types/node` happened to be in scope, so
80
- * `packages/codegen` — whose tsconfig is `lib: ["ESNext", "dom"]` — could not
81
- * compile the file at all, and both of its suites failed to run. `TextEncoder`
82
- * and `TextDecoder` are standard in both runtimes and need no ambient types.
83
- */
84
- export function toPostgresIdentifier(name: string): string {
85
- return truncateToBytes(name, 63);
86
- }
87
-
88
- /**
89
- * {@link toPostgresIdentifier} with the bound lifted to a parameter.
90
- *
91
- * Exists for names that end in something load-bearing. Truncating at 63 keeps
92
- * the *head* of a name and discards the tail, which is right for a descriptive
93
- * identifier and wrong for a hashed one: the hash is the part that makes it
94
- * unique, and it is at the end. A caller that appends a fingerprint truncates
95
- * the readable head to `63 - <tail>` itself and then appends, so the bound is
96
- * still 63 and the hash always survives.
97
- *
98
- * `contracts/derived-names.txt` records what the alternative costs — a foreign
99
- * key frozen as `..._corres`, its `_fkey` suffix truncated away, so a second
100
- * foreign key on that table would derive a byte-identical name.
101
- *
102
- * One truncation rule, in one function, so the two cannot drift.
103
- */
104
- export function truncateToBytes(name: string, maxBytes: number): string {
105
- const bytes = new TextEncoder().encode(name);
106
- if (bytes.byteLength <= maxBytes) return name;
107
- // Decoding a slice that ends mid-character yields U+FFFD; dropping it lands
108
- // on the last whole character that fits, which is what Postgres does.
109
- return new TextDecoder("utf-8").decode(bytes.subarray(0, maxBytes)).replace(/�+$/, "");
110
- }
111
-
112
- /**
113
- * The API name a database column is served under.
114
- *
115
- * The wire name of a field is its property key, and Rebase's property keys are
116
- * camelCase — `displayName`, `createdAt`, `photoURL`. Columns are snake_case,
117
- * because an unquoted Postgres identifier folds to lower case and a camelCase
118
- * column is therefore reachable only as `"authorId"` forever: in hand-written
119
- * SQL, in psql, in an RLS policy body, in a dump, and in every third-party tool
120
- * that ever touches the database. So the two conventions are both right, and
121
- * this is the function that crosses between them.
122
- *
123
- * It exists because two sources of field names never crossed: a foreign key
124
- * derived from a relation (`author_id`) and a column read back by introspection
125
- * (`user_id`) both landed on the wire under their column name, while every
126
- * hand-authored collection next to them used camelCase. One API, two
127
- * conventions, and no rule a caller could infer from outside — those names are
128
- * also the `where` and `orderBy` keys, so it was not a matter of taste.
129
- *
130
- * Rules, in the order they matter:
131
- *
132
- * - **A name with no separator is returned unchanged.** `photoURL` stays
133
- * `photoURL` and `id` stays `id`. Lower-casing a single token is what makes
134
- * a "camelCase" helper destructive — `camelCase("photoURL")` is `photourl` —
135
- * and this function is applied to names that are *already* keys.
136
- * - **Each following segment keeps its own casing** apart from an upper-cased
137
- * first letter, so `photo_URL` → `photoURL` rather than `photoUrl`.
138
- * - **The result may still not be a JavaScript identifier.** `2fa_enabled`
139
- * becomes `2faEnabled`, which is a perfectly good object key and still needs
140
- * quoting where one is written into generated source.
141
- *
142
- * Not the inverse of {@link toSnakeCase}: `toSnakeCase` tokenises on case
143
- * boundaries and would turn `photoURL` into `photo_url`. Round-tripping is not
144
- * a property either function promises, which is why a column name that a
145
- * property maps explicitly is always read off `columnName` rather than derived.
146
- */
147
- export function toWireKey(columnName: string): string {
148
- if (!columnName) return columnName;
149
- const segments = columnName.split(/[-_ ]+/).filter(Boolean);
150
- if (segments.length <= 1) return columnName;
151
- return segments
152
- .map((segment, index) =>
153
- index === 0
154
- ? segment.charAt(0).toLowerCase() + segment.slice(1)
155
- : segment.charAt(0).toUpperCase() + segment.slice(1))
156
- .join("");
157
- }
158
-
159
- /**
160
- * The first candidate key not already used, or a numbered fallback.
161
- *
162
- * Introspection turns a set of column names into a set of object keys, and the
163
- * mapping is not injective: `user_id` and `userId` are two columns and one
164
- * {@link toWireKey}, and two foreign keys can strip to the same relation name.
165
- * A duplicate key in a generated object literal is a TypeScript error, so the
166
- * whole collection stops compiling — and a duplicate key in a `Record` built at
167
- * runtime is worse, because it silently drops a column instead.
168
- *
169
- * The numbered tail is what makes this total: a function that returns a key it
170
- * cannot guarantee is free has only moved the duplicate one line down.
171
- *
172
- * Structurally typed on `has` so a `Map` of emitted blocks and a `Set` of taken
173
- * names both satisfy it. Lives here, in the package both introspection
174
- * producers and the admin's table import can reach, because they must resolve a
175
- * collision the same way or one database describes itself three ways.
176
- */
177
- export function firstFreeKey(candidates: string[], taken: { has(key: string): boolean }): string {
178
- for (const candidate of candidates) {
179
- if (!taken.has(candidate)) return candidate;
180
- }
181
- const base = candidates[candidates.length - 1];
182
- for (let suffix = 2; ; suffix++) {
183
- const candidate = `${base}_${suffix}`;
184
- if (!taken.has(candidate)) return candidate;
185
- }
186
- }