hazo_files 1.5.1 → 1.6.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/CHANGE_LOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 1.6.0 (2026-05-21)
9
+
10
+ ### Added
11
+ - **R2 Quota tracking**: Per-scope opt-in quota with threshold callbacks (`setQuotaLimit`, `getQuota`, `recomputeQuota`, `onQuotaThreshold`)
12
+ - **R4 URL import**: `importFromUrl(url, path, opts)` with SSRF protection via `hazo_secure`, 50MB default cap, `source_url` column
13
+ - **R6 Purge**: `createPurgeJobHandlers(fm)` factory + `HAZO_FILES_JOB_TYPES` constants for `hazo_jobs` integration
14
+ - **Actor tracking**: Optional `actor_id` on all `TrackedFileManager` mutations → `changed_by`/`uploaded_by` columns
15
+ - **Migrations 004-006**: `changed_by`, `source_url` columns, and `hazo_file_quotas` table
16
+ - `HAZO_FILES_MIGRATION_V4` export for programmatic V4 migration
17
+ - `HAZO_FILE_QUOTAS_TABLE_SCHEMA` export for quota table DDL
18
+ - `QuotaService`, `QuotaExceededError`, `SSRFError`, `ImportSizeCapError` exported from `hazo_files/server`
19
+ - test-app: Quota, URL Import, and Lifecycle pages with interactive scenarios
20
+
21
+ ### Not breaking
22
+ All additions are opt-in or additive. Existing 1.5.x callers work without changes.
23
+
24
+ ## [1.5.2] - 2026-05-18
25
+
26
+ ### Fixed
27
+ - `useFileUpload` no longer emits the React dev warning *"The result of getServerSnapshot should be cached to avoid an infinite loop"*. The third argument to `useSyncExternalStore` now points at a module-level frozen `EMPTY_ACTIVE_JOBS` constant instead of allocating a fresh `[]` per call, so React's reference identity check holds across renders.
28
+
8
29
  ## 1.5.0
9
30
 
10
31
  - **NEW**: `background_upload` module — UploadManager, Job, PipelineExecutor for pipelines that survive React component unmount.
package/README.md CHANGED
@@ -21,6 +21,10 @@ A powerful, modular file management package for Node.js and React applications w
21
21
  - **Content Tagging**: Optional LLM-based content classification at upload time or on-demand via `content_tag` field
22
22
  - **Schema Migrations**: Built-in V2/V3 migration utilities for adding reference tracking and content tagging to existing databases
23
23
  - **Background Upload Pipelines**: Framework-agnostic `UploadManager` + React `HazoFileUploadProvider` for multi-step upload pipelines that survive component unmount, with optional sonner toast bridge
24
+ - **Quota Tracking**: Per-scope opt-in quota with threshold callbacks and fail-open semantics
25
+ - **URL Import**: `importFromUrl` with SSRF protection, streaming size cap, and `source_url` provenance tracking
26
+ - **Actor Tracking**: Optional `actor_id` on all mutations, written to `uploaded_by` and `changed_by` columns
27
+ - **Purge Scheduler**: `createPurgeJobHandlers` factory for integrating with hazo_jobs cron workers
24
28
  - **TypeScript**: Full type safety and IntelliSense support
25
29
  - **OAuth Integration**: Built-in Google Drive and Dropbox OAuth authentication
26
30
  - **Prompt Cache Invalidation**: Passthrough for hazo_llm_api prompt cache management via server instance
@@ -1981,6 +1985,187 @@ Contributions are welcome! Please:
1981
1985
  4. Add tests for new functionality
1982
1986
  5. Submit a pull request
1983
1987
 
1988
+ ## Quota Tracking (v1.6.0)
1989
+
1990
+ Per-scope opt-in quota tracking. A scope with no quota row has no limit — uploads succeed regardless (fail-open).
1991
+
1992
+ ### Setup
1993
+
1994
+ Run migration 006 to create the `hazo_file_quotas` table (or use the `HAZO_FILE_QUOTAS_TABLE_SCHEMA` DDL export):
1995
+
1996
+ ```bash
1997
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/006_quota_tracking.sql
1998
+ ```
1999
+
2000
+ Create a CRUD service and pass it to the factory:
2001
+
2002
+ ```typescript
2003
+ import { createHazoFilesServer, HAZO_FILE_QUOTAS_TABLE_SCHEMA } from 'hazo_files/server';
2004
+
2005
+ const quotaCrud = createCrudService(adapter, HAZO_FILE_QUOTAS_TABLE_SCHEMA.tableName, {
2006
+ primaryKeys: ['scope_id'],
2007
+ autoId: { enabled: false },
2008
+ });
2009
+
2010
+ const { fileManager } = await createHazoFilesServer({
2011
+ crudService: fileCrud,
2012
+ quotaCrudService: quotaCrud,
2013
+ onQuotaThreshold: (event) => {
2014
+ console.log(`Quota ${(event.percent * 100).toFixed(0)}% for scope ${event.scopeId}`);
2015
+ },
2016
+ quotaBands: [0.80, 0.95], // default
2017
+ config: { provider: 'local', local: { basePath: './files' } },
2018
+ });
2019
+ ```
2020
+
2021
+ ### Setting Quota Limits
2022
+
2023
+ ```typescript
2024
+ // Set a 10 GB limit for a scope
2025
+ const status = await fileManager.setQuotaLimit('scope-uuid', 10 * 1024 * 1024 * 1024);
2026
+ // { scopeId, byteLimit, byteUsed, percentUsed }
2027
+
2028
+ // Check quota without uploading
2029
+ const quota = await fileManager.getQuota('scope-uuid');
2030
+ if (!quota) {
2031
+ console.log('No quota set — unlimited');
2032
+ }
2033
+ ```
2034
+
2035
+ ### Uploading with Quota Check
2036
+
2037
+ When `scope_id` is passed to `uploadFile`, the quota is checked before the upload:
2038
+
2039
+ ```typescript
2040
+ // Throws QuotaExceededError if byteUsed + fileSize > byteLimit
2041
+ await fileManager.uploadFile(buffer, '/docs/report.pdf', { scope_id: 'scope-uuid' });
2042
+ ```
2043
+
2044
+ ### Threshold Callbacks
2045
+
2046
+ The `onQuotaThreshold` callback fires for each band crossed in a single operation. Both 80% and 95% can fire from one upload:
2047
+
2048
+ ```typescript
2049
+ onQuotaThreshold: (event) => {
2050
+ if (event.percent === 0.80) notifyUser('Storage 80% full');
2051
+ if (event.percent === 0.95) blockUploads('Storage 95% full');
2052
+ }
2053
+ ```
2054
+
2055
+ ---
2056
+
2057
+ ## URL Import (v1.6.0)
2058
+
2059
+ Import a file directly from a URL into virtual storage. Requires the optional peer dependency `hazo_secure`.
2060
+
2061
+ ### Installation
2062
+
2063
+ ```bash
2064
+ npm install hazo_secure # optional peer dep
2065
+ ```
2066
+
2067
+ ### Usage
2068
+
2069
+ ```typescript
2070
+ const result = await fileManager.importFromUrl(
2071
+ 'https://cdn.example.com/documents/report.pdf',
2072
+ '/imports/report.pdf',
2073
+ {
2074
+ maxBytes: 50 * 1024 * 1024, // 50MB cap (default)
2075
+ referrer: 'https://myapp.example.com/dashboard',
2076
+ actor_id: 'user-uuid', // written to uploaded_by + changed_by
2077
+ }
2078
+ );
2079
+ // result.data: { virtualPath, size, sourceUrl }
2080
+ ```
2081
+
2082
+ The imported file's `source_url` column is set to the fetch URL for provenance tracking.
2083
+
2084
+ ### SSRF Protection
2085
+
2086
+ Configure an allowlist in the server factory to restrict importable domains:
2087
+
2088
+ ```typescript
2089
+ const { fileManager } = await createHazoFilesServer({
2090
+ ssrf: {
2091
+ allowlist: ['cdn.example.com', 'assets.myapp.com'],
2092
+ },
2093
+ // ...
2094
+ });
2095
+ ```
2096
+
2097
+ Without an allowlist, private IPs (RFC-1918) are always blocked. Blocked requests throw `SSRFError`.
2098
+
2099
+ ### Size Cap
2100
+
2101
+ If the response body exceeds `maxBytes`, the fetch is aborted and `ImportSizeCapError` is thrown. No partial file is left in storage.
2102
+
2103
+ ---
2104
+
2105
+ ## Actor Tracking (v1.6.0)
2106
+
2107
+ All `TrackedFileManager` mutation methods accept an optional `actor_id` parameter:
2108
+
2109
+ ```typescript
2110
+ await fileManager.uploadFile(buffer, '/path/file.pdf', { actor_id: 'user-uuid' });
2111
+ await fileManager.moveItem('/old', '/new', { actor_id: 'user-uuid' });
2112
+ await fileManager.renameFile('/old.pdf', 'new.pdf', { actor_id: 'user-uuid' });
2113
+ await fileManager.renameFolder('/old-dir', 'new-dir', { actor_id: 'user-uuid' });
2114
+ await fileManager.deleteFile('/path/file.pdf', { actor_id: 'user-uuid' });
2115
+ await fileManager.softDeleteFile(fileId, { actor_id: 'user-uuid' });
2116
+ ```
2117
+
2118
+ When `actor_id` is provided:
2119
+ - `uploaded_by` is set on creation (uploadFile only)
2120
+ - `changed_by` is written on every mutation
2121
+
2122
+ Run migrations 004 and 005 to add these columns to existing databases:
2123
+
2124
+ ```bash
2125
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/004_changed_by.sql
2126
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/005_source_url.sql
2127
+ ```
2128
+
2129
+ ---
2130
+
2131
+ ## Purge via hazo_jobs (v1.6.0)
2132
+
2133
+ `createPurgeJobHandlers` creates handlers compatible with hazo_jobs workers for scheduled hard-deletion of soft-deleted files.
2134
+
2135
+ ```typescript
2136
+ import { createPurgeJobHandlers, HAZO_FILES_JOB_TYPES } from 'hazo_files/server';
2137
+
2138
+ const handlers = createPurgeJobHandlers(fm, {
2139
+ submitJob: (type, payload) => jobs.submit({ type, payload }),
2140
+ });
2141
+
2142
+ // Register with your hazo_jobs worker:
2143
+ worker.register(HAZO_FILES_JOB_TYPES.PURGE_PLAN, handlers[HAZO_FILES_JOB_TYPES.PURGE_PLAN]);
2144
+ worker.register(HAZO_FILES_JOB_TYPES.PURGE_ONE, handlers[HAZO_FILES_JOB_TYPES.PURGE_ONE]);
2145
+
2146
+ // Create schedule via hazo_jobs REST or admin UI:
2147
+ await jobs.schedules.create({
2148
+ name: 'hazo_files_purge',
2149
+ cron: '30 3 * * *',
2150
+ type: HAZO_FILES_JOB_TYPES.PURGE_PLAN,
2151
+ payload: { retentionDays: 30 },
2152
+ });
2153
+ ```
2154
+
2155
+ **purge_plan**: Finds all `soft_deleted` records older than `retentionDays` and submits individual `purge_one` jobs. Supports `dryRun: true` to return `wouldPurge` list without deleting.
2156
+
2157
+ **purge_one**: Hard-deletes a single file's physical storage and DB record. Idempotent — no-op if record not found.
2158
+
2159
+ **Note**: Quota is decremented at soft-delete time, not at purge time.
2160
+
2161
+ ---
2162
+
2163
+ ## Image Processing
2164
+
2165
+ Image resizing, thumbnail generation, and EXIF extraction are provided by the companion package `hazo_images`, which depends on `hazo_files` for storage.
2166
+
2167
+ ---
2168
+
1984
2169
  ## Support
1985
2170
 
1986
2171
  - GitHub Issues: [https://github.com/pub12/hazo_files/issues](https://github.com/pub12/hazo_files/issues)
@@ -1297,6 +1297,102 @@ Run through this final checklist to ensure everything is working:
1297
1297
  - Verify hazo_files/ui is imported correctly
1298
1298
  - Check browser console for errors
1299
1299
 
1300
+ ## Part 9: V4 Setup (1.6.0 Features)
1301
+
1302
+ ### 9.1 Run Migrations 004-006
1303
+
1304
+ - [ ] Add actor tracking columns:
1305
+ ```bash
1306
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/004_changed_by.sql
1307
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/005_source_url.sql
1308
+ ```
1309
+ *(For SQLite: run the commented-out `ALTER TABLE` statements manually)*
1310
+
1311
+ - [ ] Create quota tracking table:
1312
+ ```bash
1313
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/006_quota_tracking.sql
1314
+ ```
1315
+
1316
+ ### 9.2 Quota Tracking (optional)
1317
+
1318
+ - [ ] Create CRUD service for `hazo_file_quotas`:
1319
+ ```typescript
1320
+ import { HAZO_FILE_QUOTAS_TABLE_SCHEMA } from 'hazo_files/server';
1321
+
1322
+ const quotaCrud = createCrudService(adapter, HAZO_FILE_QUOTAS_TABLE_SCHEMA.tableName, {
1323
+ primaryKeys: ['scope_id'],
1324
+ autoId: { enabled: false },
1325
+ });
1326
+ ```
1327
+
1328
+ - [ ] Pass to server factory:
1329
+ ```typescript
1330
+ const { fileManager } = await createHazoFilesServer({
1331
+ quotaCrudService: quotaCrud,
1332
+ onQuotaThreshold: (event) => {
1333
+ console.log(`Scope ${event.scopeId} at ${(event.percent * 100).toFixed(0)}%`);
1334
+ },
1335
+ // ... other options
1336
+ });
1337
+ ```
1338
+
1339
+ - [ ] Set quota limits for scopes:
1340
+ ```typescript
1341
+ await fileManager.setQuotaLimit(scopeId, 10 * 1024 * 1024 * 1024); // 10 GB
1342
+ ```
1343
+
1344
+ ### 9.3 URL Import / SSRF Guard (optional)
1345
+
1346
+ - [ ] Install optional peer dependency:
1347
+ ```bash
1348
+ npm install hazo_secure
1349
+ ```
1350
+
1351
+ - [ ] Configure SSRF allowlist in factory:
1352
+ ```typescript
1353
+ const { fileManager } = await createHazoFilesServer({
1354
+ ssrf: { allowlist: ['cdn.example.com', 'assets.myapp.com'] },
1355
+ // ...
1356
+ });
1357
+ ```
1358
+
1359
+ - [ ] Use `importFromUrl` in your API route:
1360
+ ```typescript
1361
+ const result = await fileManager.importFromUrl(url, '/imports/file.pdf', {
1362
+ actor_id: userId,
1363
+ maxBytes: 50 * 1024 * 1024,
1364
+ });
1365
+ ```
1366
+
1367
+ ### 9.4 Purge Scheduler via hazo_jobs (optional)
1368
+
1369
+ - [ ] Install hazo_jobs (separate package):
1370
+ ```bash
1371
+ npm install hazo_jobs
1372
+ ```
1373
+
1374
+ - [ ] Register purge handlers with your worker:
1375
+ ```typescript
1376
+ import { createPurgeJobHandlers, HAZO_FILES_JOB_TYPES } from 'hazo_files/server';
1377
+
1378
+ const handlers = createPurgeJobHandlers(fm, {
1379
+ submitJob: (type, payload) => jobs.submit({ type, payload }),
1380
+ });
1381
+
1382
+ worker.register(HAZO_FILES_JOB_TYPES.PURGE_PLAN, handlers[HAZO_FILES_JOB_TYPES.PURGE_PLAN]);
1383
+ worker.register(HAZO_FILES_JOB_TYPES.PURGE_ONE, handlers[HAZO_FILES_JOB_TYPES.PURGE_ONE]);
1384
+ ```
1385
+
1386
+ - [ ] Create recurring schedule:
1387
+ ```typescript
1388
+ await jobs.schedules.create({
1389
+ name: 'hazo_files_purge',
1390
+ cron: '30 3 * * *', // 3:30 AM daily
1391
+ type: HAZO_FILES_JOB_TYPES.PURGE_PLAN,
1392
+ payload: { retentionDays: 30 },
1393
+ });
1394
+ ```
1395
+
1300
1396
  ## Next Steps
1301
1397
 
1302
1398
  - [ ] Review [README.md](README.md) for advanced usage examples
@@ -391,6 +391,7 @@ function HazoFileUploadProvider({
391
391
 
392
392
  // src/background_upload/react/use-file-upload.ts
393
393
  var import_react4 = require("react");
394
+ var EMPTY_ACTIVE_JOBS = Object.freeze([]);
394
395
  function useFileUpload() {
395
396
  const manager = (0, import_react4.useContext)(FileUploadContext);
396
397
  if (!manager) {
@@ -417,7 +418,7 @@ function useFileUpload() {
417
418
  snapshot_ref.current = next;
418
419
  return next;
419
420
  },
420
- () => []
421
+ () => EMPTY_ACTIVE_JOBS
421
422
  );
422
423
  const submit_batch = (0, import_react4.useCallback)(
423
424
  (options) => manager.submit_batch(options),
@@ -351,6 +351,7 @@ function HazoFileUploadProvider({
351
351
 
352
352
  // src/background_upload/react/use-file-upload.ts
353
353
  import { useContext, useCallback, useRef as useRef2, useSyncExternalStore } from "react";
354
+ var EMPTY_ACTIVE_JOBS = Object.freeze([]);
354
355
  function useFileUpload() {
355
356
  const manager = useContext(FileUploadContext);
356
357
  if (!manager) {
@@ -377,7 +378,7 @@ function useFileUpload() {
377
378
  snapshot_ref.current = next;
378
379
  return next;
379
380
  },
380
- () => []
381
+ () => EMPTY_ACTIVE_JOBS
381
382
  );
382
383
  const submit_batch = useCallback(
383
384
  (options) => manager.submit_batch(options),