@ordinatio/trash 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +34 -2
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +62 -0
- package/dist/index.d.ts +137 -0
- package/dist/index.js +234 -0
- package/dist/purge-types-CSptXruj.d.ts +160 -0
- package/dist/scan.d.ts +29 -0
- package/dist/scan.js +222 -0
- package/package.json +56 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ordinatio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ordinatio/trash
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Trash for any product, with no database, no framework and no brand. A row that is deleted stays with `deletedAt` set; this package holds the parts every product repeats: the helpers that say "only live rows", the trash and restore state machine, the paging of the Trash list, the operator erase pipeline, and (on its own subpath) a scanner that finds reads which forgot to leave the Trash out. The product wires its tables in through small ports; the package never opens a connection, reads the clock or imports Prisma.
|
|
4
|
+
|
|
5
|
+
Three entries:
|
|
6
|
+
|
|
7
|
+
- `@ordinatio/trash` (main; imports nothing, not even `typescript`)
|
|
8
|
+
- `@ordinatio/trash/scan` (the syntax-tree scanner; needs the `typescript` package, an optional peer, and node:fs)
|
|
9
|
+
- `@ordinatio/trash/cli` (the command-line rules of the erase script; reads node:util)
|
|
10
|
+
|
|
11
|
+
## Main entry
|
|
12
|
+
|
|
13
|
+
- `live(where)` / `trashedOnly(where)`: the `where` with `deletedAt` overwritten by `null` / `{ not: null }`. The input is never changed.
|
|
14
|
+
- `createTrashService(ports)` returns `{ trash(organizationId, id, actorId), restore(organizationId, id) }`. Each call is one short transaction: limit the lock wait, lock the row, read it in ANY state, then a compare-and-set that returns how many rows it changed. Outcomes are values: `trashed` (with `before` and `deletedAt`), `alreadyTrashed`, `notFound`, `restored` (with `before`), `notTrashed`. Exactly one of ten parallel clicks wins. A lock timeout or any other fault THROWS, unchanged. Every port call carries the `organizationId`, so another organization's id is `notFound`.
|
|
15
|
+
- ports: `runTransaction(work, options)`, `limitLockWait(tx, ms)`, `transaction` (the options), `lockWaitMs`, `lock(tx, orgId, id)`, `readAnyState(tx, orgId, id)`, `markTrashed(tx, orgId, id, deletedAt, actorId)` and `markRestored(tx, orgId, id)` (both return the changed count), `now()`.
|
|
16
|
+
- `pageWindow(request, maxPageSize, defaultPageSize = 50)`: `{ page, pageSize, skip, take }` for a Trash list; the size is clamped to 1..max, the page to 1 or more.
|
|
17
|
+
- The erase pipeline for an operator who erases rows that are already in the Trash:
|
|
18
|
+
- `buildPlan(deps, db, options)`: READ-ONLY dry run. Each named id (or the trashed rows, for `--all`) becomes `erasable`, `already-erased` (an earlier audit entry), `scrub-only` (only old activity text is left), or `refused` (live, in another organization, or not found). Also gives the paste-ready confirm command.
|
|
19
|
+
- `formatPlan(plan, options, mode, hooks)`, `planExitCode(plan)`, `buildConfirmCommand(prefix, options, ids)`, `DRY_RUN_LAST_LINE`.
|
|
20
|
+
- `erase(deps, db, plan, options)`: ONE transaction per row: bounded lock wait, the model's guarded delete (still in the Trash?), scrub of the linked Activity text, recently-viewed removal and the content-free audit entry INSIDE the transaction, so a failed audit rolls the erase back. A lock timeout is `refused` with "busy, retry"; any other failure is `failed` and rolled back.
|
|
21
|
+
- `summarize(results, confirmed, caveats)`: lines and the exit code (0 only when every id is erased, already erased or scrubbed).
|
|
22
|
+
- `createPurge(deps)` binds the two to your ports. `deps` = `{ store, planners, erasers, commandPrefix }`: `store` holds the shared statements (organization, recently-viewed, audit, transaction runner, `isBusy`), `planners` and `erasers` are one per model (`ModelPlanner`, `Eraser`), keyed by the model's `noun`.
|
|
23
|
+
- `escapeLike(text)` for ILIKE searches.
|
|
24
|
+
|
|
25
|
+
## Scan entry
|
|
26
|
+
|
|
27
|
+
`scanSource(file, text, relations, models)`, `scanTree(root, models, where?)`, `sourceFiles(root, dir?)`, `countMarkers(root, dir?)`, `relationMapOf(schemaText)`, `modelOfDelegate`, `delegateOf`. It reads TypeScript as a syntax tree and reports each Prisma-style read of a trashable model that is not limited with `live(...)`/`trashedOnly(...)` or `deletedAt: null`, unless a `// trash-ok(<Model>): <reason naming organizationId scoping>` comment is directly above it. Site kinds: `read`, `dynamic`, `raw`, `nested`, `stale-marker`, `weak-marker`.
|
|
28
|
+
|
|
29
|
+
## CLI entry
|
|
30
|
+
|
|
31
|
+
`parseTrashArgs(argv, models, defaultOperator)` refuses everything unsafe before any database is touched (no organization, no selector, `--confirm` without named ids, `--all` with `--confirm`, a reason that is not a ticket reference). `models` is `[{ model, handler | 'not-trashable-yet' }]`.
|
|
32
|
+
|
|
33
|
+
## Guard rails
|
|
34
|
+
|
|
35
|
+
A source scan in the package's tests (a guard rail, not a proof) fails on `process.`, the wall clock, `fetch`, `@prisma/client` and any import other than the ones named above; a build test checks that the built main entry imports nothing at all.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { H as Handler, P as PurgeOptions } from './purge-types-CSptXruj.js';
|
|
2
|
+
|
|
3
|
+
/** A ticket reference: letters, digits and . _ : / # - only, 10 to 200 characters. No spaces and no `@`, which keeps most names and every email address out. */
|
|
4
|
+
declare const REASON_PATTERN: RegExp;
|
|
5
|
+
declare const PURGE_ALL_CAP = 50;
|
|
6
|
+
/** One Trash-enabled model: its own name (as the schema calls it) and its handler, or 'not-trashable-yet' when it has no eraser. */
|
|
7
|
+
type ModelHandler<Model extends string, H extends Handler> = {
|
|
8
|
+
model: Model;
|
|
9
|
+
handler: H | 'not-trashable-yet';
|
|
10
|
+
};
|
|
11
|
+
type ParsedTrashArgs<Model extends string, H extends Handler> = {
|
|
12
|
+
ok: true;
|
|
13
|
+
options: PurgeOptions<Model, H>;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
error: string;
|
|
17
|
+
};
|
|
18
|
+
/** Reads and validates the command line. Never touches the database. `models` is in the order the error message lists them. */
|
|
19
|
+
declare function parseTrashArgs<Model extends string, H extends Handler>(argv: string[], models: ReadonlyArray<ModelHandler<Model, H>>, defaultOperator: string): ParsedTrashArgs<Model, H>;
|
|
20
|
+
|
|
21
|
+
export { type ModelHandler, PURGE_ALL_CAP, type ParsedTrashArgs, REASON_PATTERN, parseTrashArgs };
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// src/cli.ts
|
|
2
|
+
import { parseArgs } from "util";
|
|
3
|
+
var REASON_PATTERN = /^[A-Za-z0-9._:/#-]{10,200}$/;
|
|
4
|
+
var PURGE_ALL_CAP = 50;
|
|
5
|
+
var ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
6
|
+
var OPERATOR_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
7
|
+
var refuse = (error) => ({ ok: false, error });
|
|
8
|
+
function parseTrashArgs(argv, models, defaultOperator) {
|
|
9
|
+
let values;
|
|
10
|
+
try {
|
|
11
|
+
values = parseArgs({
|
|
12
|
+
args: argv,
|
|
13
|
+
strict: true,
|
|
14
|
+
allowPositionals: false,
|
|
15
|
+
options: {
|
|
16
|
+
org: { type: "string" },
|
|
17
|
+
model: { type: "string" },
|
|
18
|
+
id: { type: "string", multiple: true },
|
|
19
|
+
all: { type: "boolean" },
|
|
20
|
+
limit: { type: "string" },
|
|
21
|
+
confirm: { type: "boolean" },
|
|
22
|
+
reason: { type: "string" },
|
|
23
|
+
operator: { type: "string" }
|
|
24
|
+
}
|
|
25
|
+
}).values;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return refuse(error instanceof Error ? error.message : "Could not read the arguments.");
|
|
28
|
+
}
|
|
29
|
+
const organizationId = typeof values.org === "string" ? values.org : "";
|
|
30
|
+
if (!ID_PATTERN.test(organizationId)) return refuse("--org <organizationId> is required (letters, digits, - and _ only).");
|
|
31
|
+
const modelArg = typeof values.model === "string" ? values.model.toLowerCase() : "";
|
|
32
|
+
const noun = (entry2) => entry2.handler === "not-trashable-yet" ? entry2.model.toLowerCase() : entry2.handler.noun;
|
|
33
|
+
const entry = models.find((m) => noun(m) === modelArg);
|
|
34
|
+
if (!entry) return refuse(`--model must be one of: ${models.map(noun).join(", ")}.`);
|
|
35
|
+
const handler = entry.handler;
|
|
36
|
+
if (handler === "not-trashable-yet") return refuse(`The ${modelArg} model has no eraser yet (it is not Trash-enabled): nothing can be erased for it.`);
|
|
37
|
+
const rawIds = Array.isArray(values.id) ? values.id.filter((v) => typeof v === "string") : [];
|
|
38
|
+
for (const id of rawIds) if (!ID_PATTERN.test(id)) return refuse(`Not a valid id: "${id.slice(0, 80)}".`);
|
|
39
|
+
const ids = [...new Set(rawIds)];
|
|
40
|
+
const all = values.all === true;
|
|
41
|
+
const confirm = values.confirm === true;
|
|
42
|
+
if (ids.length === 0 && !all) return refuse("Name what to erase: one or more --id <id>, or --all (dry run only).");
|
|
43
|
+
if (ids.length > 0 && all) return refuse("Use either --id or --all, not both.");
|
|
44
|
+
if (all && confirm) return refuse("--all is for the dry run only; --confirm needs the ids you read in the dry run (--id <id> ...).");
|
|
45
|
+
let limit = PURGE_ALL_CAP;
|
|
46
|
+
if (typeof values.limit === "string") {
|
|
47
|
+
if (!all) return refuse("--limit only applies together with --all (it caps how many trashed rows are listed).");
|
|
48
|
+
if (!/^\d{1,3}$/.test(values.limit) || Number(values.limit) < 1 || Number(values.limit) > PURGE_ALL_CAP) return refuse(`--limit must be a whole number from 1 to ${PURGE_ALL_CAP}.`);
|
|
49
|
+
limit = Number(values.limit);
|
|
50
|
+
}
|
|
51
|
+
const reason = typeof values.reason === "string" ? values.reason : null;
|
|
52
|
+
if (reason !== null && !REASON_PATTERN.test(reason)) return refuse("--reason must be a ticket reference (letters, digits and . _ : / # - only, 10 to 200 characters; no spaces or @), for example ERASE-2026-10-15-117. It is shown to everyone in the workspace, so it should be a ticket reference, not a name.");
|
|
53
|
+
if (confirm && reason === null) return refuse("--confirm needs --reason <ticket reference>.");
|
|
54
|
+
const operator = typeof values.operator === "string" ? values.operator : defaultOperator;
|
|
55
|
+
if (!OPERATOR_PATTERN.test(operator)) return refuse("--operator must be 1 to 64 characters (letters, digits and . _ - only).");
|
|
56
|
+
return { ok: true, options: { organizationId, model: entry.model, handler, ids, all, limit, confirm, reason, operator } };
|
|
57
|
+
}
|
|
58
|
+
export {
|
|
59
|
+
PURGE_ALL_CAP,
|
|
60
|
+
REASON_PATTERN,
|
|
61
|
+
parseTrashArgs
|
|
62
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { a as PurgeStore, M as ModelPlanner, P as PurgeOptions, b as PlanResult, E as Eraser, c as PurgePlan, d as EraseResult, e as ErasableRow } from './purge-types-CSptXruj.js';
|
|
2
|
+
export { A as AuditEntry, C as Cascade, f as EraseOutcome, H as Handler, R as RowPlan, g as RowRead } from './purge-types-CSptXruj.js';
|
|
3
|
+
|
|
4
|
+
/** `where` limited to live rows. The `deletedAt` of the input is overwritten, so a caller cannot ask for trashed rows through it. The input is not changed. */
|
|
5
|
+
declare function live<W extends object>(where: W): Omit<W, 'deletedAt'> & {
|
|
6
|
+
deletedAt: null;
|
|
7
|
+
};
|
|
8
|
+
/** `where` limited to rows in the Trash (the Trash page). The input is not changed. */
|
|
9
|
+
declare function trashedOnly<W extends object>(where: W): Omit<W, 'deletedAt'> & {
|
|
10
|
+
deletedAt: {
|
|
11
|
+
not: null;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
declare const DEFAULT_TRASHED_PAGE_SIZE = 50;
|
|
16
|
+
type PageRequest = {
|
|
17
|
+
page?: number;
|
|
18
|
+
pageSize?: number;
|
|
19
|
+
};
|
|
20
|
+
type PageWindow = {
|
|
21
|
+
page: number;
|
|
22
|
+
pageSize: number;
|
|
23
|
+
skip: number;
|
|
24
|
+
take: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The page size is a whole number from 1 to `maxPageSize` (missing, zero or not a number gives `defaultPageSize`; a negative number gives 1; a fraction is rounded down); the page is a whole number from 1
|
|
28
|
+
* (missing, zero or not a number gives 1). `skip` and `take` are what a store's findMany takes.
|
|
29
|
+
*/
|
|
30
|
+
declare function pageWindow(request: PageRequest | undefined, maxPageSize: number, defaultPageSize?: number): PageWindow;
|
|
31
|
+
|
|
32
|
+
type TransactionPorts<Tx, TOptions> = {
|
|
33
|
+
/** Runs `work` inside one database transaction opened with `options`; commits when `work` returns, rolls back when it throws. */
|
|
34
|
+
runTransaction<T>(work: (tx: Tx) => Promise<T>, options: TOptions): Promise<T>;
|
|
35
|
+
/** Sets the longest a statement of this transaction may wait for a row lock. Always the FIRST statement. */
|
|
36
|
+
limitLockWait(tx: Tx, ms: number): Promise<void>;
|
|
37
|
+
/** The transaction options (timeouts) of this model. */
|
|
38
|
+
transaction: TOptions;
|
|
39
|
+
/** The lock wait in milliseconds. */
|
|
40
|
+
lockWaitMs: number;
|
|
41
|
+
};
|
|
42
|
+
/** Opens the transaction, limits the lock wait first, then runs the work. Whatever throws, throws out. */
|
|
43
|
+
declare function inLockedTransaction<Tx, TOptions, T>(ports: TransactionPorts<Tx, TOptions>, work: (tx: Tx) => Promise<T>): Promise<T>;
|
|
44
|
+
|
|
45
|
+
/** What the service needs to know about a row: whether it is in the Trash. */
|
|
46
|
+
type TrashRowState = {
|
|
47
|
+
deletedAt: Date | null;
|
|
48
|
+
};
|
|
49
|
+
type TrashStorePorts<S extends TrashRowState, Tx> = {
|
|
50
|
+
/** Takes the row lock in ANY state, scoped by organization (nothing happens when the row is not there). */
|
|
51
|
+
lock(tx: Tx, organizationId: string, id: string): Promise<void>;
|
|
52
|
+
/** Reads the row in ANY state (live or trashed), scoped by organization; null when it is not in this organization. */
|
|
53
|
+
readAnyState(tx: Tx, organizationId: string, id: string): Promise<S | null>;
|
|
54
|
+
/** Compare-and-set: sets deletedAt (and who did it) only where the row is still live in this organization. Returns how many rows changed (0 or 1). */
|
|
55
|
+
markTrashed(tx: Tx, organizationId: string, id: string, deletedAt: Date, actorId: string): Promise<number>;
|
|
56
|
+
/** Compare-and-set: clears deletedAt only where the row is still in the Trash of this organization. Returns how many rows changed (0 or 1). */
|
|
57
|
+
markRestored(tx: Tx, organizationId: string, id: string): Promise<number>;
|
|
58
|
+
/** The clock, read only after the row was found live. */
|
|
59
|
+
now(): Date;
|
|
60
|
+
};
|
|
61
|
+
type TrashOutcome<S extends TrashRowState> = {
|
|
62
|
+
kind: 'trashed';
|
|
63
|
+
before: S;
|
|
64
|
+
deletedAt: Date;
|
|
65
|
+
} | {
|
|
66
|
+
kind: 'notFound';
|
|
67
|
+
} | {
|
|
68
|
+
kind: 'alreadyTrashed';
|
|
69
|
+
};
|
|
70
|
+
type RestoreOutcome<S extends TrashRowState> = {
|
|
71
|
+
kind: 'restored';
|
|
72
|
+
before: S;
|
|
73
|
+
} | {
|
|
74
|
+
kind: 'notFound';
|
|
75
|
+
} | {
|
|
76
|
+
kind: 'notTrashed';
|
|
77
|
+
};
|
|
78
|
+
type TrashService<S extends TrashRowState> = {
|
|
79
|
+
/** Moves a live row to the Trash. `before` is the row as it was read (live). A lock timeout throws. */
|
|
80
|
+
trash(organizationId: string, id: string, actorId: string): Promise<TrashOutcome<S>>;
|
|
81
|
+
/** Brings a trashed row back. `before` is the row as it was read (trashed). A lock timeout throws. */
|
|
82
|
+
restore(organizationId: string, id: string): Promise<RestoreOutcome<S>>;
|
|
83
|
+
};
|
|
84
|
+
declare function createTrashService<S extends TrashRowState, Tx, TOptions>(ports: TrashStorePorts<S, Tx> & TransactionPorts<Tx, TOptions>): TrashService<S>;
|
|
85
|
+
|
|
86
|
+
/** Escapes `\`, `%` and `_` so a searched text containing them matches only itself in an ILIKE ... ESCAPE '\'. */
|
|
87
|
+
declare const escapeLike: (text: string) => string;
|
|
88
|
+
type PlanDeps<R, Db extends R, Tx extends R, TOptions, X extends object> = {
|
|
89
|
+
store: PurgeStore<R, Db, Tx, TOptions>;
|
|
90
|
+
/** One planner per handler noun. */
|
|
91
|
+
planners: Record<string, ModelPlanner<R, X>>;
|
|
92
|
+
/** The command that runs the erase script, printed in the confirm command (for example `npx tsx scripts/purge-trashed.ts`). */
|
|
93
|
+
commandPrefix: string;
|
|
94
|
+
};
|
|
95
|
+
/** The exact command that erases what the dry run listed (ids that would change something), with the reason left for the operator to fill in. */
|
|
96
|
+
declare function buildConfirmCommand(commandPrefix: string, options: Pick<PurgeOptions, 'organizationId' | 'handler'>, ids: string[]): string;
|
|
97
|
+
/** 0 when nothing is refused; non-zero when any named id is refused. */
|
|
98
|
+
declare const planExitCode: (plan: {
|
|
99
|
+
rows: Array<{
|
|
100
|
+
kind: string;
|
|
101
|
+
}>;
|
|
102
|
+
}) => number;
|
|
103
|
+
/** READ-ONLY: reads the organization, the named rows (or up to the cap of trashed rows for --all) and what an erase would touch. Writes nothing. */
|
|
104
|
+
declare function buildPlan<R, Db extends R, Tx extends R, TOptions, X extends object = Record<never, never>>(deps: PlanDeps<R, Db, Tx, TOptions, X>, db: Db, options: PurgeOptions): Promise<PlanResult<X>>;
|
|
105
|
+
|
|
106
|
+
type EraseDeps<R, Db extends R, Tx extends R, TOptions> = {
|
|
107
|
+
store: PurgeStore<R, Db, Tx, TOptions>;
|
|
108
|
+
/** One eraser per handler noun. */
|
|
109
|
+
erasers: Record<string, Eraser<Db, Tx, TOptions>>;
|
|
110
|
+
};
|
|
111
|
+
/** Executes a plan, one transaction per erasable row. Refused rows pass through unchanged. Needs a validated ticket reference (the argument parser checks it). */
|
|
112
|
+
declare function erase<R, Db extends R, Tx extends R, TOptions, X extends object>(deps: EraseDeps<R, Db, Tx, TOptions>, db: Db, plan: PurgePlan<X>, options: Pick<PurgeOptions, 'organizationId' | 'handler' | 'operator' | 'reason'>): Promise<EraseResult[]>;
|
|
113
|
+
|
|
114
|
+
type PurgeDeps<R, Db extends R, Tx extends R, TOptions, X extends object> = PlanDeps<R, Db, Tx, TOptions, X> & EraseDeps<R, Db, Tx, TOptions>;
|
|
115
|
+
declare function createPurge<R, Db extends R, Tx extends R, TOptions, X extends object = Record<never, never>>(deps: PurgeDeps<R, Db, Tx, TOptions, X>): {
|
|
116
|
+
/** READ-ONLY dry run. */
|
|
117
|
+
buildPlan: (db: Db, options: PurgeOptions) => Promise<PlanResult<X>>;
|
|
118
|
+
/** One transaction per erasable row. */
|
|
119
|
+
erase: (db: Db, plan: PurgePlan<X>, options: Pick<PurgeOptions, "organizationId" | "handler" | "operator" | "reason">) => Promise<EraseResult[]>;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
declare const DRY_RUN_LAST_LINE = "dry run: nothing was changed";
|
|
123
|
+
type PlanFormatHooks<X extends object> = {
|
|
124
|
+
/** Extra lines for an erasable row, printed after its `extra` lines and before the "not scrubbed" line (a model-specific block, for example unlinked mail). */
|
|
125
|
+
afterExtra?: (row: ErasableRow<X>) => string[];
|
|
126
|
+
};
|
|
127
|
+
/** The lines the script prints for a plan. */
|
|
128
|
+
declare function formatPlan<X extends object = Record<never, never>>(plan: PurgePlan<X>, options: Pick<PurgeOptions, 'limit'>, mode?: {
|
|
129
|
+
confirmed: boolean;
|
|
130
|
+
}, hooks?: PlanFormatHooks<X>): string[];
|
|
131
|
+
/** Lines for the terminal and the exit code: 0 only when every named id was erased, already erased or scrubbed; anything refused or failed (or nothing named) is non-zero. `caveats` (what "erased" means for this model) are printed after a confirmed run. */
|
|
132
|
+
declare function summarize(results: EraseResult[], confirmed: boolean, caveats: readonly string[]): {
|
|
133
|
+
lines: string[];
|
|
134
|
+
exitCode: number;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export { DEFAULT_TRASHED_PAGE_SIZE, DRY_RUN_LAST_LINE, ErasableRow, type EraseDeps, EraseResult, Eraser, ModelPlanner, type PageRequest, type PageWindow, type PlanDeps, type PlanFormatHooks, PlanResult, type PurgeDeps, PurgeOptions, PurgePlan, PurgeStore, type RestoreOutcome, type TransactionPorts, type TrashOutcome, type TrashRowState, type TrashService, type TrashStorePorts, buildConfirmCommand, buildPlan, createPurge, createTrashService, erase, escapeLike, formatPlan, inLockedTransaction, live, pageWindow, planExitCode, summarize, trashedOnly };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// src/rows.ts
|
|
2
|
+
function live(where) {
|
|
3
|
+
return { ...where, deletedAt: null };
|
|
4
|
+
}
|
|
5
|
+
function trashedOnly(where) {
|
|
6
|
+
return { ...where, deletedAt: { not: null } };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// src/paging.ts
|
|
10
|
+
var DEFAULT_TRASHED_PAGE_SIZE = 50;
|
|
11
|
+
function pageWindow(request, maxPageSize, defaultPageSize = DEFAULT_TRASHED_PAGE_SIZE) {
|
|
12
|
+
const options = request ?? {};
|
|
13
|
+
const pageSize = Math.max(1, Math.min(Math.floor(options.pageSize ?? defaultPageSize) || defaultPageSize, maxPageSize));
|
|
14
|
+
const page = Math.max(1, Math.floor(options.page ?? 1) || 1);
|
|
15
|
+
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/transaction.ts
|
|
19
|
+
function inLockedTransaction(ports, work) {
|
|
20
|
+
return ports.runTransaction(async (tx) => {
|
|
21
|
+
await ports.limitLockWait(tx, ports.lockWaitMs);
|
|
22
|
+
return work(tx);
|
|
23
|
+
}, ports.transaction);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/service.ts
|
|
27
|
+
function createTrashService(ports) {
|
|
28
|
+
return {
|
|
29
|
+
trash: (organizationId, id, actorId) => inLockedTransaction(ports, async (tx) => {
|
|
30
|
+
await ports.lock(tx, organizationId, id);
|
|
31
|
+
const state = await ports.readAnyState(tx, organizationId, id);
|
|
32
|
+
if (!state) return { kind: "notFound" };
|
|
33
|
+
if (state.deletedAt !== null) return { kind: "alreadyTrashed" };
|
|
34
|
+
const deletedAt = ports.now();
|
|
35
|
+
const changed = await ports.markTrashed(tx, organizationId, id, deletedAt, actorId);
|
|
36
|
+
return changed === 1 ? { kind: "trashed", before: state, deletedAt } : { kind: "alreadyTrashed" };
|
|
37
|
+
}),
|
|
38
|
+
restore: (organizationId, id) => inLockedTransaction(ports, async (tx) => {
|
|
39
|
+
await ports.lock(tx, organizationId, id);
|
|
40
|
+
const state = await ports.readAnyState(tx, organizationId, id);
|
|
41
|
+
if (!state) return { kind: "notFound" };
|
|
42
|
+
if (state.deletedAt === null) return { kind: "notTrashed" };
|
|
43
|
+
const changed = await ports.markRestored(tx, organizationId, id);
|
|
44
|
+
return changed === 1 ? { kind: "restored", before: state } : { kind: "notTrashed" };
|
|
45
|
+
})
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/purge-plan.ts
|
|
50
|
+
var escapeLike = (text) => text.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
51
|
+
function buildConfirmCommand(commandPrefix, options, ids) {
|
|
52
|
+
const idArgs = ids.map((id) => `--id ${id}`).join(" ");
|
|
53
|
+
return `${commandPrefix} --org ${options.organizationId} --model ${options.handler.noun} ${idArgs} --confirm --reason "<ticket reference>"`;
|
|
54
|
+
}
|
|
55
|
+
var planExitCode = (plan) => plan.rows.some((r) => r.kind === "refused") ? 1 : 0;
|
|
56
|
+
function plannerOf(planners, noun) {
|
|
57
|
+
const planner = planners[noun];
|
|
58
|
+
if (!planner) throw new Error(`No planner for ${noun}.`);
|
|
59
|
+
return planner;
|
|
60
|
+
}
|
|
61
|
+
async function planRow(deps, db, options, id) {
|
|
62
|
+
const { organizationId, handler } = options;
|
|
63
|
+
const planner = plannerOf(deps.planners, handler.noun);
|
|
64
|
+
const row = await planner.read(db, organizationId, id);
|
|
65
|
+
if (row) {
|
|
66
|
+
if (row.deletedAt === null) return { kind: "refused", id, reason: `live: it is not in the Trash, and this script never erases a live ${handler.noun}` };
|
|
67
|
+
return {
|
|
68
|
+
kind: "erasable",
|
|
69
|
+
id,
|
|
70
|
+
title: row.label,
|
|
71
|
+
trashedBy: row.deletedBy,
|
|
72
|
+
trashedAt: row.deletedAt,
|
|
73
|
+
cascades: await planner.cascades(db, organizationId, id),
|
|
74
|
+
extra: await planner.extra(db, organizationId, id, row),
|
|
75
|
+
linkedActivity: await planner.linkedActivity(db, organizationId, id),
|
|
76
|
+
recentViews: await deps.store.countRecentViews(db, organizationId, handler.recentViewType, id),
|
|
77
|
+
quotingOthers: await planner.quotingOthers(db, organizationId, id, row.quoteTerms),
|
|
78
|
+
...planner.enrich ? await planner.enrich(db, organizationId, id, row) : {}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const linkedActivity = await planner.linkedActivity(db, organizationId, id);
|
|
82
|
+
const recentViews = await deps.store.countRecentViews(db, organizationId, handler.recentViewType, id);
|
|
83
|
+
if (await deps.store.findEraseAudit(db, organizationId, handler.noun, id)) return { kind: "already-erased", id, linkedActivity, recentViews };
|
|
84
|
+
if (linkedActivity > 0) return { kind: "scrub-only", id, linkedActivity, recentViews };
|
|
85
|
+
return { kind: "refused", id, reason: await planner.existsAnywhere(db, id) ? "exists in another organization: nothing written" : "not found in this organization" };
|
|
86
|
+
}
|
|
87
|
+
async function buildPlan(deps, db, options) {
|
|
88
|
+
const organization = await deps.store.findOrganization(db, options.organizationId);
|
|
89
|
+
if (!organization) return { ok: false, error: `No organization with id ${options.organizationId}: nothing was read or changed.` };
|
|
90
|
+
let ids = options.ids;
|
|
91
|
+
let capped = false;
|
|
92
|
+
if (options.all) {
|
|
93
|
+
const trashed = await plannerOf(deps.planners, options.handler.noun).listTrashed(db, options.organizationId, options.limit + 1);
|
|
94
|
+
capped = trashed.length > options.limit;
|
|
95
|
+
ids = trashed.slice(0, options.limit);
|
|
96
|
+
}
|
|
97
|
+
const rows = [];
|
|
98
|
+
for (const id of ids) rows.push(await planRow(deps, db, options, id));
|
|
99
|
+
const actionable = rows.filter((r) => r.kind !== "refused").map((r) => r.id);
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
plan: {
|
|
103
|
+
organizationId: organization.id,
|
|
104
|
+
organizationName: organization.name,
|
|
105
|
+
noun: options.handler.noun,
|
|
106
|
+
plural: options.handler.plural,
|
|
107
|
+
quoteSubject: options.handler.quoteSubject,
|
|
108
|
+
rows,
|
|
109
|
+
capped,
|
|
110
|
+
confirmCommand: actionable.length > 0 ? buildConfirmCommand(deps.commandPrefix, options, actionable) : null
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/purge-erase.ts
|
|
116
|
+
async function scrub(deps, eraser, tx, options, id) {
|
|
117
|
+
await eraser.scrubLinkedActivity(tx, options.organizationId, id, options.handler.scrubbedDescription);
|
|
118
|
+
await deps.store.removeRecentViews(tx, options.organizationId, options.handler.recentViewType, id);
|
|
119
|
+
}
|
|
120
|
+
async function writeAudit(deps, tx, options, id, scrubOnly) {
|
|
121
|
+
await deps.store.writeAudit(tx, {
|
|
122
|
+
organizationId: options.organizationId,
|
|
123
|
+
description: scrubOnly ? options.handler.scrubOnlyAuditDescription : options.handler.auditDescription,
|
|
124
|
+
metadata: { model: options.handler.noun, id, reason: options.reason, operator: options.operator, ...scrubOnly ? { kind: "scrub-only" } : {} }
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function eraseRow(deps, db, options, row) {
|
|
128
|
+
const { id, kind } = row;
|
|
129
|
+
const { organizationId, handler } = options;
|
|
130
|
+
const eraser = deps.erasers[handler.noun];
|
|
131
|
+
if (!eraser) throw new Error(`No eraser for ${handler.noun}.`);
|
|
132
|
+
let prepared;
|
|
133
|
+
try {
|
|
134
|
+
if (kind === "erasable" && eraser.prepare) prepared = await eraser.prepare(db, organizationId, id);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { id, outcome: "failed", detail: `${error instanceof Error ? error.message : "unknown error"}: nothing was changed` };
|
|
137
|
+
}
|
|
138
|
+
const ports = { transaction: eraser.transaction, lockWaitMs: eraser.lockWaitMs, limitLockWait: deps.store.limitLockWait, runTransaction: (work, o) => deps.store.runTransaction(db, work, o) };
|
|
139
|
+
return inLockedTransaction(ports, async (tx) => {
|
|
140
|
+
if (kind === "erasable") {
|
|
141
|
+
const deleted = await eraser.deleteRow(tx, organizationId, id, prepared);
|
|
142
|
+
if (!deleted.deleted) return { id, outcome: "refused", detail: "no longer in the Trash (restored or already erased): nothing was changed" };
|
|
143
|
+
await scrub(deps, eraser, tx, options, id);
|
|
144
|
+
await writeAudit(deps, tx, options, id, false);
|
|
145
|
+
return { id, outcome: "erased", detail: `row, cascades, linked activity text and recently-viewed rows removed; audit entry written${deleted.note ?? ""}` };
|
|
146
|
+
}
|
|
147
|
+
if (await eraser.stillExists(tx, organizationId, id)) return { id, outcome: "refused", detail: `a ${handler.noun} with this id exists now: nothing was changed` };
|
|
148
|
+
const audited = await deps.store.findEraseAudit(tx, organizationId, handler.noun, id);
|
|
149
|
+
await scrub(deps, eraser, tx, options, id);
|
|
150
|
+
if (audited) return { id, outcome: "already-erased", detail: "linked activity text re-scrubbed; no new audit entry" };
|
|
151
|
+
await writeAudit(deps, tx, options, id, true);
|
|
152
|
+
return { id, outcome: "scrub-only", detail: "row was gone before this script (old delete); linked activity text scrubbed; audit entry written" };
|
|
153
|
+
}).catch((error) => {
|
|
154
|
+
if (deps.store.isBusy(error)) return { id, outcome: "refused", detail: "busy, retry: the database was busy or another request holds the row; nothing was changed" };
|
|
155
|
+
return { id, outcome: "failed", detail: `${error instanceof Error ? error.message : "unknown error"}: the erase was rolled back (if this was a dropped connection at commit, check the row before retrying)` };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async function erase(deps, db, plan, options) {
|
|
159
|
+
if (options.reason === null) throw new Error("erase needs a reason (a ticket reference).");
|
|
160
|
+
if (options.organizationId !== plan.organizationId) throw new Error("The plan belongs to another organization.");
|
|
161
|
+
if (options.handler.noun !== plan.noun) throw new Error("The plan was built for another kind of row.");
|
|
162
|
+
const confirmed = { organizationId: options.organizationId, handler: options.handler, operator: options.operator, reason: options.reason };
|
|
163
|
+
const results = [];
|
|
164
|
+
for (const row of plan.rows) {
|
|
165
|
+
results.push(row.kind === "refused" ? { id: row.id, outcome: "refused", detail: row.reason } : await eraseRow(deps, db, confirmed, row));
|
|
166
|
+
}
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/purge.ts
|
|
171
|
+
function createPurge(deps) {
|
|
172
|
+
return {
|
|
173
|
+
/** READ-ONLY dry run. */
|
|
174
|
+
buildPlan: (db, options) => buildPlan(deps, db, options),
|
|
175
|
+
/** One transaction per erasable row. */
|
|
176
|
+
erase: (db, plan, options) => erase(deps, db, plan, options)
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/purge-format.ts
|
|
181
|
+
var DRY_RUN_LAST_LINE = "dry run: nothing was changed";
|
|
182
|
+
function formatPlan(plan, options, mode = { confirmed: false }, hooks = {}) {
|
|
183
|
+
const { plural, quoteSubject } = plan;
|
|
184
|
+
const out = [`Organization: ${plan.organizationName} (${plan.organizationId})`, `The audit line of a real erase shows in THIS organization's activity feed, with the ticket reference only.`];
|
|
185
|
+
if (plan.rows.length === 0) out.push(`No ${plural} are in the Trash of this organization.`);
|
|
186
|
+
for (const row of plan.rows) {
|
|
187
|
+
if (row.kind === "erasable") {
|
|
188
|
+
out.push(`ERASE ${plan.noun} ${row.id}: "${row.title}" (trashed${row.trashedBy ? ` by ${row.trashedBy}` : ""}${row.trashedAt ? ` on ${row.trashedAt.toISOString()}` : ""})`);
|
|
189
|
+
out.push(` goes with it: ${row.cascades.map((c) => `${c.count} ${c.label}`).join(", ")}`);
|
|
190
|
+
out.push(` activity entries linked to it, to be scrubbed: ${row.linkedActivity}; recently-viewed rows to remove: ${row.recentViews}`);
|
|
191
|
+
for (const line of row.extra) out.push(` ${line}`);
|
|
192
|
+
for (const line of hooks.afterExtra?.(row) ?? []) out.push(` ${line}`);
|
|
193
|
+
out.push(` not scrubbed: review by hand: ${row.quotingOthers} OTHER entries whose description or metadata text contains ${quoteSubject} (both were searched)`);
|
|
194
|
+
} else if (row.kind === "already-erased") {
|
|
195
|
+
out.push(`ALREADY ERASED ${plan.noun} ${row.id}: an earlier run left its audit entry; ${row.linkedActivity} linked entries and ${row.recentViews} recently-viewed rows would be re-scrubbed (no new audit entry)`);
|
|
196
|
+
} else if (row.kind === "scrub-only") {
|
|
197
|
+
out.push(`SCRUB ONLY ${plan.noun} ${row.id}: row was gone before this script (old delete); ${row.linkedActivity} linked entries would be scrubbed and ${row.recentViews} recently-viewed rows removed, with one audit entry`);
|
|
198
|
+
} else out.push(`REFUSED ${plan.noun} ${row.id}: ${row.reason}`);
|
|
199
|
+
}
|
|
200
|
+
if (plan.capped) out.push(`Only the first ${options.limit} trashed ${plural} are listed (the cap); there are more.`);
|
|
201
|
+
if (mode.confirmed) return out;
|
|
202
|
+
if (plan.confirmCommand) out.push("To erase exactly these, run (fill in the ticket reference):", plan.confirmCommand);
|
|
203
|
+
out.push(DRY_RUN_LAST_LINE);
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
var TITLES = { erased: "Erased", "already-erased": "Already erased", "scrub-only": "Scrubbed (row was already gone)", refused: "REFUSED", failed: "FAILED" };
|
|
207
|
+
function summarize(results, confirmed, caveats) {
|
|
208
|
+
const lines = [];
|
|
209
|
+
for (const outcome of Object.keys(TITLES)) {
|
|
210
|
+
for (const r of results.filter((x) => x.outcome === outcome)) lines.push(`${TITLES[outcome]}: ${r.id}: ${r.detail}`);
|
|
211
|
+
}
|
|
212
|
+
const good = results.filter((r) => r.outcome === "erased" || r.outcome === "already-erased" || r.outcome === "scrub-only").length;
|
|
213
|
+
const exitCode = results.length > 0 && good === results.length ? 0 : 1;
|
|
214
|
+
lines.push(`${good} of ${results.length} named ids are done${exitCode === 0 ? "." : "; the rest are listed above and need attention (exit code 1)."}`);
|
|
215
|
+
if (confirmed) lines.push(...caveats);
|
|
216
|
+
return { lines, exitCode };
|
|
217
|
+
}
|
|
218
|
+
export {
|
|
219
|
+
DEFAULT_TRASHED_PAGE_SIZE,
|
|
220
|
+
DRY_RUN_LAST_LINE,
|
|
221
|
+
buildConfirmCommand,
|
|
222
|
+
buildPlan,
|
|
223
|
+
createPurge,
|
|
224
|
+
createTrashService,
|
|
225
|
+
erase,
|
|
226
|
+
escapeLike,
|
|
227
|
+
formatPlan,
|
|
228
|
+
inLockedTransaction,
|
|
229
|
+
live,
|
|
230
|
+
pageWindow,
|
|
231
|
+
planExitCode,
|
|
232
|
+
summarize,
|
|
233
|
+
trashedOnly
|
|
234
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** What the generic erase code needs to know about one model. */
|
|
2
|
+
type Handler<Noun extends string = string> = {
|
|
3
|
+
/** Lower-case name used on the command line (`--model <noun>`) and recorded as `metadata.model` of the audit entry. */
|
|
4
|
+
noun: Noun;
|
|
5
|
+
/** Plural used in printed lines ("No people are in the Trash"). */
|
|
6
|
+
plural: string;
|
|
7
|
+
/** What the by-hand count of OTHER Activity entries searches for ("the title", "the name or email"). */
|
|
8
|
+
quoteSubject: string;
|
|
9
|
+
/** The recently-viewed `entityType` of this model. */
|
|
10
|
+
recentViewType: string;
|
|
11
|
+
/** What the linked Activity entries say after the scrub. */
|
|
12
|
+
scrubbedDescription: string;
|
|
13
|
+
/** The audit entry's description for a row that was in the Trash. */
|
|
14
|
+
auditDescription: string;
|
|
15
|
+
/** The audit entry's description for an id whose row was already gone before the script existed. */
|
|
16
|
+
scrubOnlyAuditDescription: string;
|
|
17
|
+
};
|
|
18
|
+
type PurgeOptions<Model extends string = string, H extends Handler = Handler> = {
|
|
19
|
+
organizationId: string;
|
|
20
|
+
model: Model;
|
|
21
|
+
handler: H;
|
|
22
|
+
ids: string[];
|
|
23
|
+
all: boolean;
|
|
24
|
+
/** How many trashed rows `--all` may list (1 to PURGE_ALL_CAP). */
|
|
25
|
+
limit: number;
|
|
26
|
+
confirm: boolean;
|
|
27
|
+
reason: string | null;
|
|
28
|
+
operator: string;
|
|
29
|
+
};
|
|
30
|
+
type Cascade = {
|
|
31
|
+
key: string;
|
|
32
|
+
label: string;
|
|
33
|
+
count: number;
|
|
34
|
+
};
|
|
35
|
+
/** What a model's planner reads about one row: its printed label, its Trash state and the texts other Activity entries might quote. */
|
|
36
|
+
type RowRead = {
|
|
37
|
+
label: string;
|
|
38
|
+
deletedAt: Date | null;
|
|
39
|
+
deletedBy: string | null;
|
|
40
|
+
quoteTerms: string[];
|
|
41
|
+
email?: string;
|
|
42
|
+
};
|
|
43
|
+
/** The per-model half of the dry run. Every method is READ-ONLY and every one that takes an organizationId scopes by it, except `existsAnywhere`. */
|
|
44
|
+
type ModelPlanner<R, X extends object = Record<never, never>> = {
|
|
45
|
+
/** Reads a row in ANY state of the organization. */
|
|
46
|
+
read(db: R, organizationId: string, id: string): Promise<RowRead | null>;
|
|
47
|
+
/** What cascades from the row, one counter per approved cascade. */
|
|
48
|
+
cascades(db: R, organizationId: string, id: string): Promise<Cascade[]>;
|
|
49
|
+
/** Extra printed lines. `row` is what `read` returned. */
|
|
50
|
+
extra(db: R, organizationId: string, id: string, row: RowRead): Promise<string[]>;
|
|
51
|
+
/** The Activity entries linked to the id (they are scrubbed, never deleted). */
|
|
52
|
+
linkedActivity(db: R, organizationId: string, id: string): Promise<number>;
|
|
53
|
+
/** How many OTHER Activity entries only quote the row (reviewed by hand). */
|
|
54
|
+
quotingOthers(db: R, organizationId: string, id: string, quoteTerms: string[]): Promise<number>;
|
|
55
|
+
/** Does the id exist in ANOTHER organization (or any)? Existence only: the caller prints nothing but the refusal. */
|
|
56
|
+
existsAnywhere(db: R, id: string): Promise<boolean>;
|
|
57
|
+
/** Up to `take` ids in the Trash of the organization, newest trashed first. */
|
|
58
|
+
listTrashed(db: R, organizationId: string, take: number): Promise<string[]>;
|
|
59
|
+
/** Optional extra data for the plan row of an erasable row (merged into it, after `quotingOthers`); the product prints it through `PlanFormatHooks`. */
|
|
60
|
+
enrich?(db: R, organizationId: string, id: string, row: RowRead): Promise<X>;
|
|
61
|
+
};
|
|
62
|
+
/** The per-model half of the erase transaction. */
|
|
63
|
+
type Eraser<Db, Tx, TOptions> = {
|
|
64
|
+
/** The transaction options (timeouts) of this model's erase. */
|
|
65
|
+
transaction: TOptions;
|
|
66
|
+
/** The bounded lock wait in milliseconds. */
|
|
67
|
+
lockWaitMs: number;
|
|
68
|
+
/** Lock the row, delete it only while it is STILL in the Trash of the organization, clear pointers without a foreign key. `deleted: false` (and nothing else touched) when it is no longer in the Trash. */
|
|
69
|
+
deleteRow(tx: Tx, organizationId: string, id: string, prepared: unknown): Promise<{
|
|
70
|
+
deleted: boolean;
|
|
71
|
+
note?: string;
|
|
72
|
+
}>;
|
|
73
|
+
/** Optional READ before the transaction; its result is handed to deleteRow. A throw reports the row as failed with nothing changed. */
|
|
74
|
+
prepare?(db: Db, organizationId: string, id: string): Promise<unknown>;
|
|
75
|
+
/** Replaces the text of the Activity entries linked to the id with `description` and clears their metadata (they are scrubbed, never deleted). */
|
|
76
|
+
scrubLinkedActivity(tx: Tx, organizationId: string, id: string, description: string): Promise<void>;
|
|
77
|
+
/** Does a row with this id exist now, in any state of this organization? */
|
|
78
|
+
stillExists(tx: Tx, organizationId: string, id: string): Promise<boolean>;
|
|
79
|
+
};
|
|
80
|
+
/** The content-free audit entry of an erase; the product adds its own action, severity and "system" flags. `metadata` never holds the erased content. */
|
|
81
|
+
type AuditEntry = {
|
|
82
|
+
organizationId: string;
|
|
83
|
+
description: string;
|
|
84
|
+
metadata: {
|
|
85
|
+
model: string;
|
|
86
|
+
id: string;
|
|
87
|
+
reason: string;
|
|
88
|
+
operator: string;
|
|
89
|
+
kind?: 'scrub-only';
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
/** The store half the pipeline shares across models. */
|
|
93
|
+
type PurgeStore<R, Db extends R, Tx extends R, TOptions> = {
|
|
94
|
+
findOrganization(db: Db, organizationId: string): Promise<{
|
|
95
|
+
id: string;
|
|
96
|
+
name: string;
|
|
97
|
+
} | null>;
|
|
98
|
+
countRecentViews(db: R, organizationId: string, entityType: string, id: string): Promise<number>;
|
|
99
|
+
/** The audit entry an earlier erase wrote for the id, if any (matched on organization, model and id). */
|
|
100
|
+
findEraseAudit(db: R, organizationId: string, noun: string, id: string): Promise<boolean>;
|
|
101
|
+
removeRecentViews(tx: Tx, organizationId: string, entityType: string, id: string): Promise<void>;
|
|
102
|
+
/** Writes the audit entry INSIDE the erase transaction; a failure must throw (it rolls the erase back). */
|
|
103
|
+
writeAudit(tx: Tx, entry: AuditEntry): Promise<void>;
|
|
104
|
+
runTransaction<T>(db: Db, work: (tx: Tx) => Promise<T>, options: TOptions): Promise<T>;
|
|
105
|
+
limitLockWait(tx: Tx, ms: number): Promise<void>;
|
|
106
|
+
/** True when the error means "busy, retry" (a lock timeout, a pool that could not start the transaction), false for a fault. */
|
|
107
|
+
isBusy(error: unknown): boolean;
|
|
108
|
+
};
|
|
109
|
+
type ErasableRow<X extends object = Record<never, never>> = {
|
|
110
|
+
kind: 'erasable';
|
|
111
|
+
id: string;
|
|
112
|
+
title: string;
|
|
113
|
+
trashedBy: string | null;
|
|
114
|
+
trashedAt: Date | null;
|
|
115
|
+
cascades: Cascade[];
|
|
116
|
+
extra: string[];
|
|
117
|
+
linkedActivity: number;
|
|
118
|
+
recentViews: number;
|
|
119
|
+
quotingOthers: number;
|
|
120
|
+
} & X;
|
|
121
|
+
type RowPlan<X extends object = Record<never, never>> = ErasableRow<X> | {
|
|
122
|
+
kind: 'already-erased';
|
|
123
|
+
id: string;
|
|
124
|
+
linkedActivity: number;
|
|
125
|
+
recentViews: number;
|
|
126
|
+
} | {
|
|
127
|
+
kind: 'scrub-only';
|
|
128
|
+
id: string;
|
|
129
|
+
linkedActivity: number;
|
|
130
|
+
recentViews: number;
|
|
131
|
+
} | {
|
|
132
|
+
kind: 'refused';
|
|
133
|
+
id: string;
|
|
134
|
+
reason: string;
|
|
135
|
+
};
|
|
136
|
+
type PurgePlan<X extends object = Record<never, never>> = {
|
|
137
|
+
organizationId: string;
|
|
138
|
+
organizationName: string;
|
|
139
|
+
noun: string;
|
|
140
|
+
plural: string;
|
|
141
|
+
quoteSubject: string;
|
|
142
|
+
rows: RowPlan<X>[];
|
|
143
|
+
capped: boolean;
|
|
144
|
+
confirmCommand: string | null;
|
|
145
|
+
};
|
|
146
|
+
type PlanResult<X extends object = Record<never, never>> = {
|
|
147
|
+
ok: true;
|
|
148
|
+
plan: PurgePlan<X>;
|
|
149
|
+
} | {
|
|
150
|
+
ok: false;
|
|
151
|
+
error: string;
|
|
152
|
+
};
|
|
153
|
+
type EraseOutcome = 'erased' | 'already-erased' | 'scrub-only' | 'refused' | 'failed';
|
|
154
|
+
type EraseResult = {
|
|
155
|
+
id: string;
|
|
156
|
+
outcome: EraseOutcome;
|
|
157
|
+
detail: string;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export type { AuditEntry as A, Cascade as C, Eraser as E, Handler as H, ModelPlanner as M, PurgeOptions as P, RowPlan as R, PurgeStore as a, PlanResult as b, PurgePlan as c, EraseResult as d, ErasableRow as e, EraseOutcome as f, RowRead as g };
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** parent model name -> (field name -> target model name). */
|
|
2
|
+
type RelationMap = Map<string, Map<string, string>>;
|
|
3
|
+
/** Builds the relation map from the text of schema.prisma. Comments are ignored; scalar, enum and native-typed fields are not relations. */
|
|
4
|
+
declare function relationMapOf(schema: string): RelationMap;
|
|
5
|
+
/** The client delegate name of a model: `Contact` -> `contact`, `EmailMessage` -> `emailMessage`. */
|
|
6
|
+
declare const delegateOf: (model: string) => string;
|
|
7
|
+
/** The model a delegate name belongs to, or undefined when no model has that delegate. */
|
|
8
|
+
declare function modelOfDelegate(map: RelationMap, delegate: string): string | undefined;
|
|
9
|
+
|
|
10
|
+
type SiteKind = 'read' | 'dynamic' | 'raw' | 'nested' | 'stale-marker' | 'weak-marker';
|
|
11
|
+
type Site = {
|
|
12
|
+
file: string;
|
|
13
|
+
line: number;
|
|
14
|
+
kind: SiteKind;
|
|
15
|
+
model: string;
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
declare function scanSource(file: string, text: string, relations: RelationMap, models: readonly string[]): Site[];
|
|
19
|
+
/** Every non-test source file under `dir` (relative to the project root). */
|
|
20
|
+
declare function sourceFiles(root: string, dir?: string): string[];
|
|
21
|
+
/** Scans the whole tree for the given models. The schema is read from `prisma/schema.prisma` and the sources from `src`, unless `where` says otherwise. */
|
|
22
|
+
declare function scanTree(root: string, models: readonly string[], where?: {
|
|
23
|
+
schemaPath?: string;
|
|
24
|
+
sourceDir?: string;
|
|
25
|
+
}): Site[];
|
|
26
|
+
/** How many `// trash-ok` markers the source tree holds (the test pins this number so an addition shows in a diff). */
|
|
27
|
+
declare function countMarkers(root: string, sourceDir?: string): number;
|
|
28
|
+
|
|
29
|
+
export { type RelationMap, type Site, type SiteKind, countMarkers, delegateOf, modelOfDelegate, relationMapOf, scanSource, scanTree, sourceFiles };
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// src/scan.ts
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
|
|
6
|
+
// src/scan-schema.ts
|
|
7
|
+
function relationMapOf(schema) {
|
|
8
|
+
const clean = schema.replace(/(^|\s)\/\/.*$/gm, "$1");
|
|
9
|
+
const models = /* @__PURE__ */ new Map();
|
|
10
|
+
for (const m of clean.matchAll(/^model (\w+) \{([\s\S]*?)^\}/gm)) models.set(m[1], m[2]);
|
|
11
|
+
const map = /* @__PURE__ */ new Map();
|
|
12
|
+
for (const [name, text] of models) {
|
|
13
|
+
const fields = /* @__PURE__ */ new Map();
|
|
14
|
+
for (const line of text.split("\n")) {
|
|
15
|
+
const field = /^\s*(\w+)\s+(\w+)(\[\]|\?)?(\s|$)/.exec(line);
|
|
16
|
+
if (field && models.has(field[2])) fields.set(field[1], field[2]);
|
|
17
|
+
}
|
|
18
|
+
map.set(name, fields);
|
|
19
|
+
}
|
|
20
|
+
return map;
|
|
21
|
+
}
|
|
22
|
+
var delegateOf = (model) => model.charAt(0).toLowerCase() + model.slice(1);
|
|
23
|
+
function modelOfDelegate(map, delegate) {
|
|
24
|
+
return [...map.keys()].find((model) => delegateOf(model) === delegate);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/scan.ts
|
|
28
|
+
var NESTED_ONLY_OPS = /* @__PURE__ */ new Set(["create", "update", "upsert", "delete"]);
|
|
29
|
+
var READ_OPS = /* @__PURE__ */ new Set(["findMany", "findFirst", "findFirstOrThrow", "findUnique", "findUniqueOrThrow", "count", "groupBy", "aggregate"]);
|
|
30
|
+
var RAW_CALLS = /* @__PURE__ */ new Set(["$queryRaw", "$queryRawUnsafe", "$executeRaw", "$executeRawUnsafe"]);
|
|
31
|
+
var MARKER = /^\s*\/\/\s*trash-ok\((\w+)\):\s*(.*)$/;
|
|
32
|
+
var HOW = "wrap its where in live(...) or add `// trash-ok(<Model>): <reason that names its organizationId scoping>` directly above it";
|
|
33
|
+
var HOW_DYNAMIC = "the model is chosen at run time, so live(...) cannot be checked: add `// trash-ok(dynamic): <reason that names its organizationId scoping and how trashed rows are handled>` directly above it";
|
|
34
|
+
var unwrap = (node) => ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node) || ts.isTypeAssertionExpression(node) ? unwrap(node.expression) : node;
|
|
35
|
+
var propName = (p) => p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) ? p.name.text : void 0;
|
|
36
|
+
var propsOf = (obj) => obj && ts.isObjectLiteralExpression(unwrap(obj)) ? unwrap(obj).properties : [];
|
|
37
|
+
var isNull = (node) => unwrap(node).kind === ts.SyntaxKind.NullKeyword;
|
|
38
|
+
var isNotNull = (node) => {
|
|
39
|
+
const value = unwrap(node);
|
|
40
|
+
return ts.isObjectLiteralExpression(value) && value.properties.some((p) => ts.isPropertyAssignment(p) && propName(p) === "not" && isNull(p.initializer));
|
|
41
|
+
};
|
|
42
|
+
var isScope = (n) => ts.isBlock(n) || ts.isSourceFile(n) || ts.isCaseBlock(n) || ts.isModuleBlock(n);
|
|
43
|
+
function scanSource(file, text, relations, models) {
|
|
44
|
+
if (models.length === 0) return [];
|
|
45
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
46
|
+
const lines = text.split("\n");
|
|
47
|
+
const lineOf = (node) => source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
48
|
+
const sites = [];
|
|
49
|
+
const markers = [];
|
|
50
|
+
lines.forEach((content, index) => {
|
|
51
|
+
const m = MARKER.exec(content);
|
|
52
|
+
if (m) markers.push({ line: index + 1, model: m[1], reason: m[2], used: false });
|
|
53
|
+
});
|
|
54
|
+
const markerAbove = (line, model) => {
|
|
55
|
+
for (let at = line - 1; at >= 1; at--) {
|
|
56
|
+
const marker = markers.find((m) => m.line === at);
|
|
57
|
+
if (!marker) return void 0;
|
|
58
|
+
if (marker.model === model) return marker;
|
|
59
|
+
}
|
|
60
|
+
return void 0;
|
|
61
|
+
};
|
|
62
|
+
const declared = /* @__PURE__ */ new Map();
|
|
63
|
+
const collect = (n) => {
|
|
64
|
+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer) {
|
|
65
|
+
let scope = n.parent;
|
|
66
|
+
while (scope && !isScope(scope)) scope = scope.parent;
|
|
67
|
+
if (scope) {
|
|
68
|
+
const byName = declared.get(scope) ?? /* @__PURE__ */ new Map();
|
|
69
|
+
byName.set(n.name.text, [...byName.get(n.name.text) ?? [], n.initializer]);
|
|
70
|
+
declared.set(scope, byName);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
ts.forEachChild(n, collect);
|
|
74
|
+
};
|
|
75
|
+
collect(source);
|
|
76
|
+
const declarationsOf = (id) => {
|
|
77
|
+
for (let scope = id.parent; scope; scope = scope.parent) {
|
|
78
|
+
const found = isScope(scope) ? declared.get(scope)?.get(id.text) : void 0;
|
|
79
|
+
if (found) return found;
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
};
|
|
83
|
+
const resolved = (value) => {
|
|
84
|
+
const inner = unwrap(value);
|
|
85
|
+
return ts.isIdentifier(inner) ? [inner, ...declarationsOf(inner)] : [inner];
|
|
86
|
+
};
|
|
87
|
+
const filteredWhere = (node, depth = 0) => {
|
|
88
|
+
const e = unwrap(node);
|
|
89
|
+
if (ts.isCallExpression(e)) return ts.isIdentifier(e.expression) && (e.expression.text === "live" || e.expression.text === "trashedOnly");
|
|
90
|
+
if (ts.isIdentifier(e)) return depth < 3 && declarationsOf(e).some((d) => filteredWhere(d, depth + 1));
|
|
91
|
+
if (ts.isArrayLiteralExpression(e)) return e.elements.some((el) => filteredWhere(el, depth + 1));
|
|
92
|
+
if (!ts.isObjectLiteralExpression(e)) return false;
|
|
93
|
+
return e.properties.some((p, index) => {
|
|
94
|
+
if (e.properties.slice(index + 1).some((later) => ts.isSpreadAssignment(later))) return false;
|
|
95
|
+
if (ts.isSpreadAssignment(p)) return filteredWhere(p.expression, depth + 1);
|
|
96
|
+
if (!ts.isPropertyAssignment(p)) return false;
|
|
97
|
+
const key = propName(p);
|
|
98
|
+
if (key === "deletedAt") return isNull(p.initializer) || isNotNull(p.initializer);
|
|
99
|
+
return key === "AND" ? filteredWhere(p.initializer, depth + 1) : false;
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const report = (node, kind, model, why) => {
|
|
103
|
+
const line = lineOf(node);
|
|
104
|
+
if (kind === "stale-marker" || kind === "weak-marker") {
|
|
105
|
+
sites.push({ file, line, kind, model, message: `${file}:${line} ${why}` });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const marker = markerAbove(line, kind === "dynamic" ? "dynamic" : model);
|
|
109
|
+
if (marker) {
|
|
110
|
+
marker.used = true;
|
|
111
|
+
if (marker.reason.trim().length < 10 || !/organizationId|org-scoped/.test(marker.reason)) sites.push({ file, line: marker.line, kind: "weak-marker", model, message: `${file}:${marker.line} trash-ok(${marker.model}) needs a reason of 10+ characters that names its organizationId scoping` });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
sites.push({ file, line, kind, model, message: kind === "dynamic" ? `${file}:${line} ${why}: ${HOW_DYNAMIC}` : `${file}:${line} ${why} without a trash filter: ${HOW}` });
|
|
115
|
+
};
|
|
116
|
+
const whereFiltered = (args) => {
|
|
117
|
+
for (const p of propsOf(args[0])) {
|
|
118
|
+
if (propName(p) !== "where") continue;
|
|
119
|
+
const value = ts.isShorthandPropertyAssignment(p) ? p.name : ts.isPropertyAssignment(p) ? p.initializer : void 0;
|
|
120
|
+
if (value && filteredWhere(value)) return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
};
|
|
124
|
+
const checkNested = (parentModel, args, call) => {
|
|
125
|
+
const walk = (parent, node) => {
|
|
126
|
+
for (const p of propsOf(node)) {
|
|
127
|
+
const key = propName(p);
|
|
128
|
+
if (key !== "include" && key !== "select") continue;
|
|
129
|
+
const holder = ts.isShorthandPropertyAssignment(p) ? p.name : ts.isPropertyAssignment(p) ? p.initializer : void 0;
|
|
130
|
+
for (const group of holder ? resolved(holder) : []) {
|
|
131
|
+
for (const rel of propsOf(group)) {
|
|
132
|
+
const relName = propName(rel);
|
|
133
|
+
const target = relName ? relations.get(parent)?.get(relName) : void 0;
|
|
134
|
+
if (!relName || !target) continue;
|
|
135
|
+
const value = ts.isPropertyAssignment(rel) ? rel.initializer : void 0;
|
|
136
|
+
if (models.includes(target) && !(value && resolved(value).some((v) => propsOf(v).some((x) => propName(x) === "where" && ts.isPropertyAssignment(x) && filteredWhere(x.initializer))))) {
|
|
137
|
+
report(call, "nested", target, `${parent}.${relName} is included`);
|
|
138
|
+
}
|
|
139
|
+
if (value) walk(target, value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
walk(parentModel, args[0]);
|
|
145
|
+
};
|
|
146
|
+
const isDynamic = (callee) => {
|
|
147
|
+
const target = unwrap(callee.expression);
|
|
148
|
+
if (ts.isElementAccessExpression(target)) return !ts.isStringLiteral(target.argumentExpression);
|
|
149
|
+
if (ts.isIdentifier(target)) return declarationsOf(target).some((d) => ts.isElementAccessExpression(unwrap(d)) && !ts.isStringLiteral(unwrap(d).argumentExpression));
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
const rawModels = (literal) => models.filter((model) => literal.includes(`"${model}"`) && !literal.includes('"deletedAt"'));
|
|
153
|
+
const literalText = (node) => {
|
|
154
|
+
if (ts.isNoSubstitutionTemplateLiteral(node) || ts.isStringLiteral(node)) return node.text;
|
|
155
|
+
if (ts.isTemplateExpression(node)) return node.head.text + node.templateSpans.map((s) => s.literal.text).join(" ");
|
|
156
|
+
return void 0;
|
|
157
|
+
};
|
|
158
|
+
const visit = (node) => {
|
|
159
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
160
|
+
const callee = node.expression;
|
|
161
|
+
const op = callee.name.text;
|
|
162
|
+
if (READ_OPS.has(op) || NESTED_ONLY_OPS.has(op)) {
|
|
163
|
+
const holder = unwrap(callee.expression);
|
|
164
|
+
const delegate = ts.isPropertyAccessExpression(holder) ? holder.name.text : void 0;
|
|
165
|
+
const model = delegate ? modelOfDelegate(relations, delegate) : void 0;
|
|
166
|
+
if (model && ts.isPropertyAccessExpression(holder)) {
|
|
167
|
+
if (READ_OPS.has(op) && models.includes(model) && !whereFiltered(node.arguments)) report(node, "read", model, `${model}.${op} reads`);
|
|
168
|
+
checkNested(model, node.arguments, node);
|
|
169
|
+
} else if (READ_OPS.has(op) && isDynamic(callee)) {
|
|
170
|
+
report(node, "dynamic", "dynamic", `a dynamically chosen model's ${op} reads`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (RAW_CALLS.has(callee.name.text)) {
|
|
174
|
+
for (const arg of node.arguments.slice(0, 1)) {
|
|
175
|
+
const text2 = literalText(arg);
|
|
176
|
+
if (text2 !== void 0) for (const model of rawModels(text2)) report(node, "raw", model, `raw SQL on "${model}" reads`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
181
|
+
const tag = node.tag.getText(source);
|
|
182
|
+
if (tag === "Prisma.sql" || tag === "sql" || RAW_CALLS.has(tag.split(".").pop() ?? "")) {
|
|
183
|
+
const text2 = literalText(node.template);
|
|
184
|
+
if (text2 !== void 0) for (const model of rawModels(text2)) report(node, "raw", model, `raw SQL on "${model}" reads`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
ts.forEachChild(node, visit);
|
|
188
|
+
};
|
|
189
|
+
visit(source);
|
|
190
|
+
for (const marker of markers) {
|
|
191
|
+
if (!marker.used) sites.push({ file, line: marker.line, kind: "stale-marker", model: marker.model, message: `${file}:${marker.line} trash-ok(${marker.model}) excuses no read directly below it: remove it` });
|
|
192
|
+
}
|
|
193
|
+
return sites.sort((a, b) => a.line - b.line);
|
|
194
|
+
}
|
|
195
|
+
function sourceFiles(root, dir = "src") {
|
|
196
|
+
const out = [];
|
|
197
|
+
const walk = (current) => {
|
|
198
|
+
for (const name of readdirSync(join(root, current))) {
|
|
199
|
+
const path = join(current, name);
|
|
200
|
+
if (statSync(join(root, path)).isDirectory()) walk(path);
|
|
201
|
+
else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$|\.d\.ts$/.test(name)) out.push(path);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
walk(dir);
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
function scanTree(root, models, where = {}) {
|
|
208
|
+
const relations = relationMapOf(readFileSync(join(root, where.schemaPath ?? join("prisma", "schema.prisma")), "utf8"));
|
|
209
|
+
return sourceFiles(root, where.sourceDir).flatMap((file) => scanSource(file, readFileSync(join(root, file), "utf8"), relations, models));
|
|
210
|
+
}
|
|
211
|
+
function countMarkers(root, sourceDir) {
|
|
212
|
+
return sourceFiles(root, sourceDir).reduce((total, file) => total + readFileSync(join(root, file), "utf8").split("\n").filter((line) => MARKER.test(line)).length, 0);
|
|
213
|
+
}
|
|
214
|
+
export {
|
|
215
|
+
countMarkers,
|
|
216
|
+
delegateOf,
|
|
217
|
+
modelOfDelegate,
|
|
218
|
+
relationMapOf,
|
|
219
|
+
scanSource,
|
|
220
|
+
scanTree,
|
|
221
|
+
sourceFiles
|
|
222
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ordinatio/trash",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "A Trash for any product, with no database and no framework: row-state helpers, the trash and restore state machine behind a store port (outcomes as values, one winner under parallel clicks), trashed-list paging, the operator erase pipeline (read-only plan, one transaction per row, audit inside it, exit codes, command-line rules) and, on its own subpath, a syntax-tree scanner that finds reads which forgot to leave the Trash out",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.0.0"
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./scan": {
|
|
20
|
+
"types": "./dist/scan.d.ts",
|
|
21
|
+
"import": "./dist/scan.js",
|
|
22
|
+
"default": "./dist/scan.js"
|
|
23
|
+
},
|
|
24
|
+
"./cli": {
|
|
25
|
+
"types": "./dist/cli.d.ts",
|
|
26
|
+
"import": "./dist/cli.js",
|
|
27
|
+
"default": "./dist/cli.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"sideEffects": false,
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"typescript": ">=5"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"typescript": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"tsup": "^8.5.1",
|
|
50
|
+
"typescript": "^5.8.3",
|
|
51
|
+
"vitest": "^4.0.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsup",
|
|
55
|
+
"typecheck": "tsc --noEmit",
|
|
56
|
+
"test": "vitest",
|
|
57
|
+
"test:run": "vitest run"
|
|
58
|
+
}
|
|
6
59
|
}
|