diff --git a/.changeset/26046-released-file-keeps-record-rule.md b/.changeset/26046-released-file-keeps-record-rule.md new file mode 100644 index 00000000000..0da921f3cb1 --- /dev/null +++ b/.changeset/26046-released-file-keeps-record-rule.md @@ -0,0 +1,21 @@ +--- +'@objectstack/service-storage': patch +--- + +fix(storage)!: a file its record lets go keeps that record's read rule + +ADR-0104 D3 promises one file, one owning record and one access rule. That rule now holds after the owning record lets the file go: until a writer who may read that record claims the file again, or the platform collects it, the file is read by that record's rule. The download door and the record-write claim path ask the same verdict, so both follow it. A file no record has owned is read as before, within its organization. The uploader still reads their own file. + +`sys_file` gains two nullable fields, `released_ref_object` and `released_ref_id`, naming the record that last owned the file. The platform writes them when a record lets a file go and clears them when a record claims it. Existing rows hold NULL and are read as before. + +Clause-②: no (narrowing) — a file read that a record's rule refuses is now refused after the record lets the file go. Nothing that was refused becomes accepted. + +Metadata-protocol: none (a file read rule and two platform-managed bookkeeping fields; nothing a metadata author writes changes) + + + +**BREAKING** for a reader the owning record's rule refuses, shipped as `patch` because the metadata protocol does not move. + +- **Before.** The owning record's rule did not hold for the file once the record let it go. +- **Now.** It is read by the rule of the record that let it go, until it is claimed again or collected. +- **Fix.** Read or claim such a file as a reader of that record. diff --git a/docs/adr/0104-field-runtime-value-shape-contract.md b/docs/adr/0104-field-runtime-value-shape-contract.md index 37a812c5540..86d7ec994de 100644 --- a/docs/adr/0104-field-runtime-value-shape-contract.md +++ b/docs/adr/0104-field-runtime-value-shape-contract.md @@ -1459,9 +1459,11 @@ exists in the acting organization. One file had two access rules. - **One judgement, two callers.** The door's verdict is one function, and the claim path calls it under the write's caller context; neither restates it. It admits the uploader; a reader of the record that owns the file, through - its object's `fileAccessDelegate` where one is declared; a member of the - organization for a file no record owns; a reader of any parent for an - attachments-scope file; anyone for a `public_read` file. + its object's `fileAccessDelegate` where one is declared; for a file a record + let go, a reader of that record, until the file is claimed again or + collected (#26046); a member of the organization for a file no record has + owned; a reader of any parent for an attachments-scope file; anyone for a + `public_read` file. - **The quiet answer.** At the write door, a file the writer may not read is answered as one the organization-scoped lookup cannot see: the id is kept as written, nothing is claimed or copied, and constraint verdicts do not run diff --git a/packages/services/service-storage/src/file-reference-lifecycle.ts b/packages/services/service-storage/src/file-reference-lifecycle.ts index a20601e3c08..a08b784f2c4 100644 --- a/packages/services/service-storage/src/file-reference-lifecycle.ts +++ b/packages/services/service-storage/src/file-reference-lifecycle.ts @@ -65,6 +65,17 @@ import { callerContext } from './attachment-access-hooks.js'; * and keep any other id as written; neither is a file the anonymous caller * may provide. An empty or absent file field is written as before. * + * ### A released file keeps its record's rule + * + * [#26046] Releasing a file clears its ownership columns, and the download + * door reads a file no record owns by the organization's rule. That rule is + * for a file no record ever held; a file a record let go is still the one that + * record held. So release keeps the record ({@link releaseOwnership}), the + * door's verdict judges the file by that record's read access until it is + * claimed again or collected, and a claim, which asks the same verdict, is + * open only to a writer who may read that record. A deleted record is read by + * nobody, so its released files are left to their uploader. + * * ### The one write that cannot honour it, and is refused * * [#7102] A predicate (`multi: true`) update has ONE payload for N matched @@ -803,6 +814,12 @@ async function claimFile( patch.status = 'committed'; patch.deleted_at = null; } + // [#26046] The claiming record's rule replaces the one the file kept from + // the record that let it go. + if (row.released_ref_object != null || row.released_ref_id != null) { + patch.released_ref_object = null; + patch.released_ref_id = null; + } await engine.update('sys_file', patch, { context: { ...SYSTEM_CTX } }); } @@ -848,6 +865,13 @@ async function collectionOpen(engine: FileReferenceEngine): Promise { * Only a `committed` file is tombstoned: `pending` rows already carry their * own never-completed reap policy, and re-tombstoning a `deleted` row would * reset its grace clock. + * + * [#26046] Every release keeps the record that let the file go + * (`released_ref_object` / `released_ref_id`), whether or not it tombstones: + * the file is read by that record's rule until a writer who may read it + * claims it again ({@link claimFile} clears them), or it is collected. Without + * them a released file would read as one no record ever held, whose rule is + * the organization's, which is weaker than the record's was. */ async function releaseOwnership( engine: FileReferenceEngine, @@ -861,7 +885,14 @@ async function releaseOwnership( if (id == null) continue; const tombstone = collect && row.status === 'committed'; try { - const patch: Record = { id, ref_object: null, ref_id: null, ref_field: null }; + const patch: Record = { + id, + ref_object: null, + ref_id: null, + ref_field: null, + released_ref_object: row.ref_object ?? null, + released_ref_id: row.ref_id ?? null, + }; if (tombstone) { patch.status = 'deleted'; patch.deleted_at = new Date().toISOString(); diff --git a/packages/services/service-storage/src/file-reference-release-read-rule.integration.test.ts b/packages/services/service-storage/src/file-reference-release-read-rule.integration.test.ts new file mode 100644 index 00000000000..142c2220cec --- /dev/null +++ b/packages/services/service-storage/src/file-reference-release-read-rule.integration.test.ts @@ -0,0 +1,369 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #26046 — a file its owning record lets go keeps that record's read rule. + * + * ADR-0104 D3: one file, one owning record, one access rule. Until a writer + * who may read it claims it again, or the platform collects it, a file its + * record let go is still the one that record held, so it is read by that + * record's rule. + * + * ## What is pinned + * + * One real `ObjectQL` engine over sqlite `:memory:`, with `StorageServicePlugin` + * booted and wired exactly as it ships, asked through both doors that judge a + * file by the download door's verdict: the download door + * (`GET /files/:fileId/url`) and a record write naming the file (the claim + * path, #25692). Two members of one organization: one who may read the record + * that held the file and one who may not. For every way a file leaves its + * slot, under both collection settings: + * + * - the member refused the record is refused the file, on both doors; + * - the member who reads the record still reads it, and may take it back; + * - once the record is deleted, only the uploader reads the file. + * + * And the rules this sits beside, unchanged: a file no record ever held is + * read within its organization (#25693), and a write's claim makes the + * claiming record the file's one owner. + * + * ## The stand-ins, and only these + * + * - The security layer's refusals: an engine middleware that throws + * `PERMISSION_DENIED` for every non-system read of `sys_file`, and for + * the refused member's reads of `vault`. + * - The permission store the door's context resolution reads: reads of an + * object this fixture never registered answer empty. + * - The deployment's file-as-reference migration flag, which decides + * whether a released file is tombstoned. + * - Authentication: one `x-test-user` header per session. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { IStorageService, RouteHandler } from '@objectstack/spec/contracts'; +import { RAW_FILE_VALUES_CONTEXT_KEY, type EngineQueryOptions } from '@objectstack/spec/data'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { StorageServicePlugin } from './storage-service-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const URL_ROUTE = '/api/v1/storage/files/:fileId/url'; +const SYS = { isSystem: true }; +const SYS_RAW = { isSystem: true, [RAW_FILE_VALUES_CONTEXT_KEY]: true }; +/** A member of the organization the `vault` records are refused to. */ +const OUTSIDER = 'u_outsider'; +/** A member of the organization who reads the `vault` records. */ +const READER = 'u_reader'; +/** Who uploaded every fixture file. */ +const UPLOADER = 'u_uploader'; + +const silentLogger = () => ({ + info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn(), + trace: vi.fn(), fatal: vi.fn(), child() { return this; }, +}); + +/** The refusal the security layer throws — `PermissionDeniedError`'s declared envelope. */ +const refusal = () => + Object.assign(new Error('You do not have permission to perform this action.'), { + name: 'PermissionDeniedError', code: 'PERMISSION_DENIED', status: 403, statusCode: 403, + }); + +const fileRow = (id: string, extra: Record = {}) => ({ + id, + key: `files/${id}.pdf`, + name: `${id}.pdf`, + size: 2048, + mime_type: 'application/pdf', + scope: 'user', + acl: 'private', + status: 'committed', + owner_id: UPLOADER, + ...extra, +}); +const owned = (object: string, recordId: string, field: string) => ({ + ref_object: object, ref_id: recordId, ref_field: field, +}); + +/** Each way a file leaves its field slot, as the reader's write. */ +type Release = { + name: string; + /** The file the record lets go. */ + file: string; + /** Does the record survive the release? */ + recordSurvives: boolean; + release: (engine: ObjectQL) => Promise; +}; +const RELEASES: Release[] = [ + { + name: 'release 1 of 4', + file: 'f_replaced', + recordSurvives: true, + release: (engine) => + engine.update('vault', { id: 'v_replaced', doc: 'f_replacement' } as any, { context: { userId: READER } }), + }, + { + name: 'release 2 of 4', + file: 'f_cleared', + recordSurvives: true, + release: (engine) => + engine.update('vault', { id: 'v_cleared', doc: null } as any, { context: { userId: READER } }), + }, + { + name: 'release 3 of 4', + file: 'f_dropped', + recordSurvives: true, + release: (engine) => + engine.update('vault', { id: 'v_multi', annexes: ['f_kept'] } as any, { context: { userId: READER } }), + }, + { + name: 'release 4 of 4', + file: 'f_deleted', + recordSurvives: false, + release: (engine) => + engine.delete('vault', { where: { id: 'v_deleted' }, context: { userId: READER } } as any), + }, +]; + +const FILES = [ + fileRow('f_replaced', owned('vault', 'v_replaced', 'doc')), + fileRow('f_replacement', { owner_id: READER }), + fileRow('f_cleared', owned('vault', 'v_cleared', 'doc')), + fileRow('f_dropped', owned('vault', 'v_multi', 'annexes')), + fileRow('f_kept', owned('vault', 'v_multi', 'annexes')), + fileRow('f_deleted', owned('vault', 'v_deleted', 'doc')), + /** No record ever held it: an upload of the organization. */ + fileRow('f_free'), +]; + +async function boot(tombstones: boolean) { + const rootDir = join(tmpdir(), `os-26046-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(rootDir, { recursive: true }); + + const engine = new ObjectQL({ logger: silentLogger() } as any); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }) as any, true); + await engine.init(); + + // The data door the plugin is handed: the real engine, except that a read of + // an object this fixture never registered answers empty, and the migration + // flag is this deployment's. + const dataEngine = new Proxy(engine, { + get(target, prop) { + if (prop === 'find') { + return (object: string, query?: EngineQueryOptions) => + target.getSchema(object) ? target.find(object, query) : Promise.resolve([]); + } + if (prop === 'isFileReferencesMigrationVerified') return async () => tombstones; + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + + const services = new Map(); + const ready: Array<() => Promise | void> = []; + const routes = new Map(); + const put = (m: string) => (path: string, handler: RouteHandler) => { routes.set(`${m}:${path}`, handler); }; + const ctx = { + logger: silentLogger(), + registerService: (name: string, svc: unknown) => { services.set(name, svc); }, + getService: (name: string): T => { + if (!services.has(name)) throw new Error(`service '${name}' not registered`); + return services.get(name) as T; + }, + hook: (event: string, fn: () => Promise | void) => { if (event === 'kernel:ready') ready.push(fn); }, + }; + ctx.registerService('objectql', dataEngine); + ctx.registerService('http-server', { + get: put('GET'), post: put('POST'), put: put('PUT'), + delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }); + ctx.registerService('manifest', { register: (manifest: any) => engine.registerApp(manifest) }); + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const userId = headers.get('x-test-user'); + return userId ? { user: { id: userId }, session: {} } : undefined; + }, + }, + }); + + const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }); + await plugin.init(ctx as any); + + engine.registerApp({ + id: 'com.objectstack.test.file-reference-release-read-rule', + name: 'File reference release read rule', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { name: 'contract', label: 'Contract', fields: { doc: { name: 'doc', type: 'file' } } }, + { + name: 'vault', + label: 'Vault', + fields: { + doc: { name: 'doc', type: 'file' }, + annexes: { name: 'annexes', type: 'file', multiple: true }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + + // Seeded BEFORE kernel:ready, so the field-reference hooks do not rewrite + // the fixture. + for (const f of FILES) await engine.insert('sys_file', f as any, { context: SYS }); + await engine.insert('vault', { id: 'v_replaced', doc: 'f_replaced' } as any, { context: SYS }); + await engine.insert('vault', { id: 'v_cleared', doc: 'f_cleared' } as any, { context: SYS }); + await engine.insert('vault', { id: 'v_multi', annexes: ['f_dropped', 'f_kept'] } as any, { context: SYS }); + await engine.insert('vault', { id: 'v_deleted', doc: 'f_deleted' } as any, { context: SYS }); + + engine.registerMiddleware(async (op: any, next: () => Promise) => { + if (!op.context?.isSystem) throw refusal(); + return next(); + }, { object: 'sys_file' }); + engine.registerMiddleware(async (op: any, next: () => Promise) => { + if (!op.context?.isSystem && op.context?.userId === OUTSIDER) throw refusal(); + return next(); + }, { object: 'vault' }); + + await plugin.start(ctx as any); + for (const h of ready) await h(); + + const storage = ctx.getService('storage'); + for (const f of FILES) await storage.upload(f.key, Buffer.from(`bytes of ${f.id}`)); + + /** What the download door tells `user` about one file. */ + const door = async (fileId: string, user: string) => { + const res: any = { + _status: 200, _json: null, + json(data: any) { res._json = data; return res; }, + send() { return res; }, + status(code: number) { res._status = code; return res; }, + header() { return res; }, + }; + await routes.get(`GET:${URL_ROUTE}`)!( + { params: { fileId }, query: {}, headers: { 'x-test-user': user }, method: 'GET', path: URL_ROUTE } as any, + res, + ); + return { status: res._status as number, code: res._json?.error?.code as string | undefined }; + }; + + const fileById = async (id: string) => + ((await engine.find('sys_file', { where: { id }, context: SYS })) as Array>)[0]; + + /** + * Write `fileId` into a new contract's `doc` as `user`, and report what the + * write did with it: `claimed` (the record owns the file it named) or `kept` + * (the record holds the id as written and owns nothing). + */ + let seq = 0; + const writeAs = async (fileId: string, user: string) => { + const recordId = `c_${++seq}`; + await engine.insert('contract', { id: recordId, doc: fileId } as any, { context: { userId: user } }); + const held = ((await engine.find('contract', { where: { id: recordId }, context: SYS_RAW })) as any[])[0]?.doc; + const row = await fileById(fileId); + const owns = row?.ref_object === 'contract' && row?.ref_id === recordId && row?.ref_field === 'doc'; + return held === fileId && owns ? 'claimed' : held === fileId && !owns ? 'kept' : `unexpected (held ${String(held)})`; + }; + + return { + engine, door, fileById, writeAs, + teardown: async () => { + try { await engine.destroy(); } catch { /* already torn down */ } + await fs.rm(rootDir, { recursive: true, force: true }); + }, + }; +} + +const ALLOWED = { status: 200, code: undefined }; +const DENIED = { status: 403, code: 'FILE_DOWNLOAD_DENIED' }; + +describe('#26046 — a file its owning record lets go keeps that record\'s read rule (collection closed)', () => { + let h: Awaited>; + beforeAll(async () => { + h = await boot(false); + for (const r of RELEASES) { + expect(await h.door(r.file, OUTSIDER)).toEqual(DENIED); + await r.release(h.engine); + expect((await h.fileById(r.file))?.ref_id ?? null).toBeNull(); + } + }); + afterAll(async () => { await h?.teardown(); }); + + describe.each(RELEASES)('$name', (r) => { + it('a member refused the record is refused the file, at the download door and at a write', async () => { + expect(await h.door(r.file, OUTSIDER)).toEqual(DENIED); + expect(await h.writeAs(r.file, OUTSIDER)).toBe('kept'); + expect((await h.fileById(r.file))?.ref_id ?? null).toBeNull(); + }); + + it('the uploader still reads it', async () => { + expect(await h.door(r.file, UPLOADER)).toEqual(ALLOWED); + }); + + it( + r.recordSurvives + ? 'CONTROL — a reader of the record still reads it, and may take it back' + : 'with the record gone, its reader is refused too', + async () => { + if (!r.recordSurvives) { + expect(await h.door(r.file, READER)).toEqual(DENIED); + expect(await h.writeAs(r.file, READER)).toBe('kept'); + return; + } + expect(await h.door(r.file, READER)).toEqual(ALLOWED); + expect(await h.writeAs(r.file, READER)).toBe('claimed'); + // Claimed, the file is the claiming record's, and read by its rule: + // every member reads a `contract` here. + expect(await h.door(r.file, OUTSIDER)).toEqual(ALLOWED); + }, + ); + }); + + it('CONTROL — a file no record ever held is read within its organization, and claimed (#25693)', async () => { + expect(await h.door('f_free', OUTSIDER)).toEqual(ALLOWED); + expect(await h.writeAs('f_free', OUTSIDER)).toBe('claimed'); + }); +}); + +describe('#26046 — a file its owning record lets go keeps that record\'s read rule (collection open)', () => { + let h: Awaited>; + beforeAll(async () => { + h = await boot(true); + for (const r of RELEASES) { + await r.release(h.engine); + expect(await h.fileById(r.file)).toMatchObject({ status: 'deleted', ref_id: null }); + } + }); + afterAll(async () => { await h?.teardown(); }); + + describe.each(RELEASES)('$name', (r) => { + it('a write by a member refused the record neither takes nor revives the file', async () => { + expect(await h.writeAs(r.file, OUTSIDER)).toBe('kept'); + expect(await h.fileById(r.file)).toMatchObject({ status: 'deleted', ref_id: null }); + }); + + it( + r.recordSurvives + ? 'CONTROL — a reader of the record may take it back, which revives it' + : 'with the record gone, its reader may not take it back either', + async () => { + if (!r.recordSurvives) { + expect(await h.writeAs(r.file, READER)).toBe('kept'); + expect(await h.fileById(r.file)).toMatchObject({ status: 'deleted', ref_id: null }); + return; + } + expect(await h.writeAs(r.file, READER)).toBe('claimed'); + expect(await h.fileById(r.file)).toMatchObject({ status: 'committed' }); + }, + ); + }); +}); diff --git a/packages/services/service-storage/src/metadata-store.ts b/packages/services/service-storage/src/metadata-store.ts index dabaa18cf83..b9c7a21098b 100644 --- a/packages/services/service-storage/src/metadata-store.ts +++ b/packages/services/service-storage/src/metadata-store.ts @@ -29,6 +29,11 @@ export interface FileRecord { ref_object?: string | null; ref_id?: string | null; ref_field?: string | null; + /** [#26046] The record whose field last owned this file, kept when it let + * the file go; the file is read by that record's rule until claimed again. + * NULL on every file no record has let go. */ + released_ref_object?: string | null; + released_ref_id?: string | null; /** * The registry-injected tenant column (#12745). * diff --git a/packages/services/service-storage/src/objects/system-file.object.ts b/packages/services/service-storage/src/objects/system-file.object.ts index 656831697a5..15924561baa 100644 --- a/packages/services/service-storage/src/objects/system-file.object.ts +++ b/packages/services/service-storage/src/objects/system-file.object.ts @@ -160,6 +160,27 @@ export const SystemFile = ObjectSchema.create({ group: 'Field Reference', }), + // [#26046] The record whose field last owned this file, kept when that + // record lets it go (the ownership columns above are cleared then). A file + // a record let go is read by that record's rule until a writer who may + // read it claims it again, or it is collected — never by the weaker rule + // of a file no record ever held. Cleared by the next claim. NULL on every + // file no record has let go. Never a holder: the reap guard reads the + // ownership columns above, not these. + released_ref_object: Field.text({ + label: 'Released From Object', + maxLength: 128, + description: 'Short object name of the record that last owned this file, after it let the file go', + group: 'Field Reference', + }), + + released_ref_id: Field.text({ + label: 'Released From Record', + maxLength: 64, + description: 'Primary key of the record that last owned this file, after it let the file go', + group: 'Field Reference', + }), + metadata: Field.text({ label: 'Metadata (JSON)', }), diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index 329a8af6636..24540c7fe6b 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -1591,6 +1591,16 @@ async function judgeFileRead( return readable.has(ownerId) ? 'allow' : 'deny'; } + // [#26046] Released: a record let this file go, and no record has claimed + // it since. It is still the file that record held, so it keeps that + // record's read rule, asked exactly as the arm above asks it. A record that + // no longer exists is read by nobody, so only the uploader reads the file. + if (file.released_ref_object && file.released_ref_id != null && file.released_ref_id !== '') { + const formerId = String(file.released_ref_id); + const readable = await readableFieldOwners(registry, engine, String(file.released_ref_object), [formerId], context); + return readable.has(formerId) ? 'allow' : 'deny'; + } + // [#25693] Unclaimed (neither an attachments scope nor a field owner): // no parent record to derive access from, so the file is read as every // other organization-scoped row is — within the caller's organization, diff --git a/packages/services/service-storage/src/translations/en.objects.generated.ts b/packages/services/service-storage/src/translations/en.objects.generated.ts index a8bda94427b..1cb43c37c4b 100644 --- a/packages/services/service-storage/src/translations/en.objects.generated.ts +++ b/packages/services/service-storage/src/translations/en.objects.generated.ts @@ -82,6 +82,14 @@ export const enObjects: NonNullable = { label: "Referencing Field", help: "Field name on the owning record that holds this file id" }, + released_ref_object: { + label: "Released From Object", + help: "Short object name of the record that last owned this file, after it let the file go" + }, + released_ref_id: { + label: "Released From Record", + help: "Primary key of the record that last owned this file, after it let the file go" + }, metadata: { label: "Metadata (JSON)" }, diff --git a/packages/services/service-storage/src/translations/es-ES.objects.generated.ts b/packages/services/service-storage/src/translations/es-ES.objects.generated.ts index 3d3a86ee5bf..4503f709ac4 100644 --- a/packages/services/service-storage/src/translations/es-ES.objects.generated.ts +++ b/packages/services/service-storage/src/translations/es-ES.objects.generated.ts @@ -82,6 +82,14 @@ export const esESObjects: NonNullable = { label: "Campo referenciador", help: "Nombre del campo del registro propietario que contiene este id de archivo" }, + released_ref_object: { + label: "Objeto que la liberó", + help: "Nombre corto del objeto del registro que fue el último propietario de este archivo, tras liberarlo" + }, + released_ref_id: { + label: "Registro que la liberó", + help: "Clave principal del registro que fue el último propietario de este archivo, tras liberarlo" + }, metadata: { label: "Metadatos (JSON)" }, diff --git a/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts b/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts index 2a08c1d720a..e1e830192ad 100644 --- a/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts +++ b/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts @@ -82,6 +82,14 @@ export const jaJPObjects: NonNullable = { label: "参照元フィールド", help: "このファイル ID を保持する、所有レコード側のフィールド名" }, + released_ref_object: { + label: "解放元オブジェクト", + help: "このファイルを最後に所有し、解放したレコードのオブジェクト短縮名" + }, + released_ref_id: { + label: "解放元レコード", + help: "このファイルを最後に所有し、解放したレコードの主キー" + }, metadata: { label: "メタデータ(JSON)" }, diff --git a/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts b/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts index bf7922c8bb4..0dd8fd6cfd5 100644 --- a/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts +++ b/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts @@ -82,6 +82,14 @@ export const zhCNObjects: NonNullable = { label: "引用方字段", help: "所属记录上保存该文件 ID 的字段名" }, + released_ref_object: { + label: "释放来源对象", + help: "最后拥有该文件、并已将其释放的记录所属对象的短名称" + }, + released_ref_id: { + label: "释放来源记录", + help: "最后拥有该文件、并已将其释放的记录的主键" + }, metadata: { label: "元数据(JSON)" },