@omnifyjp/ts 5.0.5 → 5.1.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.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Generates `config/omnify.php` — Laravel runtime config for the audit-log
3
+ * feature.
4
+ *
5
+ * The trait reads `config('omnify.audit.logExclude')`, `config('omnify.audit.logQueue')`,
6
+ * and `config('omnify.audit.logRetention')` at request time. We emit those
7
+ * values from the resolved schemas.json so the trait + Audit model stay in
8
+ * sync with whatever the user declared in `omnify.yaml`.
9
+ *
10
+ * Each value is wrapped in `env(...)` so operators can override per
11
+ * environment without re-running codegen — production might use a longer
12
+ * retention than dev, or a dedicated audits queue, without touching YAML.
13
+ */
14
+ import { SchemaReader } from './schema-reader.js';
15
+ import type { GeneratedFile, PhpConfig } from './types.js';
16
+ /** Generate config/omnify.php with audit-log settings. */
17
+ export declare function generateAuditConfig(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Generates `config/omnify.php` — Laravel runtime config for the audit-log
3
+ * feature.
4
+ *
5
+ * The trait reads `config('omnify.audit.logExclude')`, `config('omnify.audit.logQueue')`,
6
+ * and `config('omnify.audit.logRetention')` at request time. We emit those
7
+ * values from the resolved schemas.json so the trait + Audit model stay in
8
+ * sync with whatever the user declared in `omnify.yaml`.
9
+ *
10
+ * Each value is wrapped in `env(...)` so operators can override per
11
+ * environment without re-running codegen — production might use a longer
12
+ * retention than dev, or a dedicated audits queue, without touching YAML.
13
+ */
14
+ import { baseFile } from './types.js';
15
+ /** Render a JS string as a single-quoted PHP string literal. PHP accepts
16
+ * both quoting styles, but single quotes are the idiomatic choice for
17
+ * plain strings (no variable interpolation, no escape processing) and
18
+ * match the rest of the generator's output. Embedded single quotes and
19
+ * backslashes are escaped per PHP single-quoted-string rules. */
20
+ function phpStr(s) {
21
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
22
+ }
23
+ /** Generate config/omnify.php with audit-log settings. */
24
+ export function generateAuditConfig(reader, config) {
25
+ if (!reader.hasAuditLog())
26
+ return [];
27
+ const auditCfg = reader.getAuditConfig();
28
+ const excludes = auditCfg?.logExclude ?? [];
29
+ const retention = auditCfg?.logRetention ?? '';
30
+ const queue = auditCfg?.logQueue ?? '';
31
+ const excludeLines = excludes.length > 0
32
+ ? excludes.map((c) => ` ${phpStr(c)},`).join('\n')
33
+ : '';
34
+ const configPath = config.rootPath
35
+ ? `${config.rootPath}/config/omnify.php`
36
+ : 'config/omnify.php';
37
+ const content = `<?php
38
+
39
+ // Auto-generated by Omnify. DO NOT EDIT.
40
+ //
41
+ // This file mirrors the \`audit:\` block from omnify.yaml so the
42
+ // HasOmnifyAuditLog trait, WriteAuditLog job, and Audit model can read
43
+ // runtime settings via Laravel's config(). Each value can be overridden
44
+ // per environment via the env() variables below.
45
+
46
+ return [
47
+ 'audit' => [
48
+ // Sensitive columns scrubbed from old_values / new_values BEFORE
49
+ // any audit row is written. Per-schema \`options.audit.logExclude\`
50
+ // adds to this list at runtime — they are NOT a replacement.
51
+ 'logExclude' => [
52
+ ${excludeLines}
53
+ ],
54
+
55
+ // Prunable retention period (e.g. "90d", "12w", "6m", "1y").
56
+ // Empty string keeps audits forever (no scheduled prune).
57
+ // Override per environment with OMNIFY_AUDIT_RETENTION.
58
+ 'logRetention' => env('OMNIFY_AUDIT_RETENTION', ${phpStr(retention)}),
59
+
60
+ // Laravel queue name for WriteAuditLog dispatch. Empty string
61
+ // forces synchronous dispatch — only acceptable in dev or for
62
+ // very low-traffic apps. Production should set this to a queue
63
+ // name backed by a real driver (redis, sqs, etc.).
64
+ // Override per environment with OMNIFY_AUDIT_QUEUE.
65
+ 'logQueue' => env('OMNIFY_AUDIT_QUEUE', ${phpStr(queue)}),
66
+ ],
67
+ ];
68
+ `;
69
+ return [baseFile(configPath, content)];
70
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Generates the audit-log persistence layer:
3
+ * - app/Jobs/WriteAuditLog.php — queued (or sync) job that inserts a
4
+ * prepared audit payload into the audits table.
5
+ * - app/Models/Audit.php — Eloquent model with polymorphic auditable +
6
+ * user relations and Prunable retention support.
7
+ *
8
+ * The trait (HasOmnifyAuditLog) does ALL Auth + Request + scrub work on
9
+ * the request thread, so the job's only responsibility is the insert.
10
+ * That's deliberate: workers run with no Auth context, no Request facade,
11
+ * and no session — anything captured at job HANDLE time would be wrong.
12
+ *
13
+ * The Audit model uses Laravel's Prunable trait so `php artisan model:prune`
14
+ * (scheduled hourly / daily by the consumer) deletes rows older than the
15
+ * configured retention. When `omnify.audit.retention` is empty the
16
+ * `prunable()` query matches no rows (audits are kept forever).
17
+ */
18
+ import { SchemaReader } from './schema-reader.js';
19
+ import type { GeneratedFile, PhpConfig } from './types.js';
20
+ /** Generate the WriteAuditLog job + Audit model. */
21
+ export declare function generateAuditObserver(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Generates the audit-log persistence layer:
3
+ * - app/Jobs/WriteAuditLog.php — queued (or sync) job that inserts a
4
+ * prepared audit payload into the audits table.
5
+ * - app/Models/Audit.php — Eloquent model with polymorphic auditable +
6
+ * user relations and Prunable retention support.
7
+ *
8
+ * The trait (HasOmnifyAuditLog) does ALL Auth + Request + scrub work on
9
+ * the request thread, so the job's only responsibility is the insert.
10
+ * That's deliberate: workers run with no Auth context, no Request facade,
11
+ * and no session — anything captured at job HANDLE time would be wrong.
12
+ *
13
+ * The Audit model uses Laravel's Prunable trait so `php artisan model:prune`
14
+ * (scheduled hourly / daily by the consumer) deletes rows older than the
15
+ * configured retention. When `omnify.audit.retention` is empty the
16
+ * `prunable()` query matches no rows (audits are kept forever).
17
+ */
18
+ import { baseFile } from './types.js';
19
+ /** Generate the WriteAuditLog job + Audit model. */
20
+ export function generateAuditObserver(reader, config) {
21
+ // Generation is gated by index.ts; defensively no-op if called when the
22
+ // feature is off so callers can't accidentally emit dead infra.
23
+ if (!reader.hasAuditLog())
24
+ return [];
25
+ const files = [];
26
+ files.push(generateWriteAuditLogJob(config));
27
+ files.push(generateAuditModel(reader, config));
28
+ return files;
29
+ }
30
+ function generateWriteAuditLogJob(config) {
31
+ const modelNamespace = config.models.namespace;
32
+ // Jobs always live at the conventional `app/Jobs/...` location. The
33
+ // path is rooted under `config.rootPath` so monorepo setups (Laravel
34
+ // in a subdir) still write to the right place.
35
+ const jobsPath = config.rootPath
36
+ ? `${config.rootPath}/app/Jobs`
37
+ : 'app/Jobs';
38
+ const jobsNamespace = 'App\\Jobs';
39
+ const content = `<?php
40
+
41
+ namespace ${jobsNamespace};
42
+
43
+ /**
44
+ * Persists a single audit-log row to the audits table.
45
+ *
46
+ * The trait builds the payload on the request thread (so Auth + Request
47
+ * data is captured correctly) and dispatches this job. The handler does
48
+ * nothing but the insert — it is intentionally trivial so it can run on
49
+ * cheap workers and survive retries cleanly.
50
+ *
51
+ * DO NOT EDIT — auto-generated by Omnify.
52
+ *
53
+ * @generated by omnify
54
+ */
55
+
56
+ use ${modelNamespace}\\Audit;
57
+ use Illuminate\\Bus\\Queueable;
58
+ use Illuminate\\Contracts\\Queue\\ShouldQueue;
59
+ use Illuminate\\Foundation\\Bus\\Dispatchable;
60
+ use Illuminate\\Queue\\InteractsWithQueue;
61
+ use Illuminate\\Queue\\SerializesModels;
62
+
63
+ class WriteAuditLog implements ShouldQueue
64
+ {
65
+ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
66
+
67
+ /**
68
+ * @param array<string, mixed> $payload Pre-scrubbed insert row
69
+ * (auditable_type, auditable_id,
70
+ * event, old_values, new_values,
71
+ * user_type, user_id, url,
72
+ * ip_address, user_agent, tags,
73
+ * created_at).
74
+ */
75
+ public function __construct(public array $payload)
76
+ {
77
+ }
78
+
79
+ /**
80
+ * Default retry behaviour: try 3 times with 5-second backoff between
81
+ * attempts. Audit rows are append-only and idempotent at the row
82
+ * level (no unique constraint trips on retry), so retries are safe.
83
+ *
84
+ * @return array<int, int>
85
+ */
86
+ public function backoff(): array
87
+ {
88
+ return [5, 30, 120];
89
+ }
90
+
91
+ public int $tries = 3;
92
+
93
+ public function handle(): void
94
+ {
95
+ Audit::create($this->payload);
96
+ }
97
+ }
98
+ `;
99
+ return baseFile(`${jobsPath}/WriteAuditLog.php`, content);
100
+ }
101
+ function generateAuditModel(reader, config) {
102
+ const modelNamespace = config.models.namespace;
103
+ const retention = reader.getAuditRetention();
104
+ // The retention parser is embedded in the model so the consumer can
105
+ // change `omnify.audit.retention` (or its environment variable) without
106
+ // re-generating. Empty retention = keep forever (prunable matches nothing).
107
+ const content = `<?php
108
+
109
+ namespace ${modelNamespace};
110
+
111
+ /**
112
+ * Audit history row.
113
+ *
114
+ * Polymorphic on both \`auditable_*\` (the model whose state changed) and
115
+ * \`user_*\` (the actor who triggered the change). No FK constraints on
116
+ * either side so records survive when the source row is hard-deleted —
117
+ * which is the whole point of an audit trail. Append-only: \`updated_at\`
118
+ * is NOT present on this table.
119
+ *
120
+ * Uses Laravel's Prunable trait. Wire it up in your scheduler:
121
+ *
122
+ * // app/Console/Kernel.php
123
+ * \\$schedule->command('model:prune', ['--model' => [\\App\\Models\\Audit::class]])->daily();
124
+ *
125
+ * Set retention via the \`omnify.audit.logRetention\` config key
126
+ * (e.g. \`90d\`, \`12w\`, \`6m\`, \`1y\`). An empty string disables prune
127
+ * (audits are kept forever).
128
+ *
129
+ * DO NOT EDIT — auto-generated by Omnify.
130
+ *
131
+ * @generated by omnify
132
+ */
133
+
134
+ use Illuminate\\Database\\Eloquent\\Builder;
135
+ use Illuminate\\Database\\Eloquent\\Model;
136
+ use Illuminate\\Database\\Eloquent\\Prunable;
137
+ use Illuminate\\Database\\Eloquent\\Relations\\MorphTo;
138
+
139
+ class Audit extends Model
140
+ {
141
+ use Prunable;
142
+
143
+ /** Audits are append-only — no \`updated_at\` column on this table. */
144
+ public $timestamps = false;
145
+
146
+ /** @var list<string> */
147
+ protected $fillable = [
148
+ 'auditable_type',
149
+ 'auditable_id',
150
+ 'event',
151
+ 'old_values',
152
+ 'new_values',
153
+ 'user_type',
154
+ 'user_id',
155
+ 'url',
156
+ 'ip_address',
157
+ 'user_agent',
158
+ 'tags',
159
+ 'created_at',
160
+ ];
161
+
162
+ /** @var array<string, string> */
163
+ protected $casts = [
164
+ 'old_values' => 'array',
165
+ 'new_values' => 'array',
166
+ 'created_at' => 'datetime',
167
+ ];
168
+
169
+ /**
170
+ * The audited record. \`auditable_id\` is unconstrained so the audit
171
+ * row survives a hard-delete of the source row.
172
+ */
173
+ public function auditable(): MorphTo
174
+ {
175
+ return $this->morphTo();
176
+ }
177
+
178
+ /**
179
+ * The actor who triggered the change. Null \`user_*\` columns
180
+ * indicate a system-driven write (CLI, scheduled job, internal API).
181
+ */
182
+ public function user(): MorphTo
183
+ {
184
+ return $this->morphTo();
185
+ }
186
+
187
+ /**
188
+ * Prunable scope. Reads \`omnify.audit.logRetention\` at runtime so the
189
+ * consumer can change retention without regenerating code.
190
+ *
191
+ * - \`90d\` → 90 days
192
+ * - \`12w\` → 12 weeks
193
+ * - \`6m\` → 6 months
194
+ * - \`1y\` → 1 year
195
+ *
196
+ * Empty / unparseable retention → matches no rows (kept forever).
197
+ */
198
+ public function prunable(): Builder
199
+ {
200
+ $retention = (string) config('omnify.audit.logRetention', ${JSON.stringify(retention)});
201
+ if ($retention === '') {
202
+ return static::query()->whereRaw('1 = 0');
203
+ }
204
+
205
+ if (!preg_match('/^(\\\\d+)([dwmy])$/', $retention, $m)) {
206
+ // Misconfigured retention — fail safe by keeping all rows
207
+ // rather than silently deleting based on a guessed period.
208
+ return static::query()->whereRaw('1 = 0');
209
+ }
210
+
211
+ $n = (int) $m[1];
212
+ $cutoff = match ($m[2]) {
213
+ 'd' => now()->subDays($n),
214
+ 'w' => now()->subWeeks($n),
215
+ 'm' => now()->subMonths($n),
216
+ 'y' => now()->subYears($n),
217
+ };
218
+
219
+ return static::query()->where('created_at', '<', $cutoff);
220
+ }
221
+ }
222
+ `;
223
+ // Audit model lives next to the rest of the generated models so
224
+ // morphTo's class resolution finds it via the standard namespace.
225
+ return baseFile(`${config.models.path}/Audit.php`, content);
226
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Generates the HasOmnifyAuditLog trait for models with audit-log opt-in.
3
+ *
4
+ * The trait is the single hook point that connects every audited Eloquent
5
+ * model to the central `audits` history table. Adding `use HasOmnifyAuditLog;`
6
+ * to a model makes it record `created` / `updated` / `deleted` / `restored`
7
+ * events, with dirty-only diffing on update and sensitive-column scrubbing
8
+ * before serialization.
9
+ *
10
+ * Performance design (audit-log is a hot path on every write):
11
+ * - WriteAuditLog is dispatched as a queued job when `omnify.audit.queue`
12
+ * is non-empty. The web request returns immediately; the audit row is
13
+ * inserted by a worker. Sync fallback is only used in dev / low-traffic
14
+ * mode (queue == "").
15
+ * - Auth, URL, IP, and User-Agent are captured at DISPATCH time, not at
16
+ * job HANDLE time — workers have no Auth context and no Request facade.
17
+ * - Updated events skip dispatch entirely when `getDirty()` is empty
18
+ * (re-saves with no real changes don't emit phantom audit rows).
19
+ * - Sensitive columns listed in `$auditExclude` are stripped from
20
+ * `old_values` / `new_values` BEFORE the JSON payload is built, so a
21
+ * stolen audits dump can't leak passwords or two-factor secrets.
22
+ * - String columns are truncated to fit the table schema (`url` 2048,
23
+ * `user_agent` 1023, `tags` 255) so a hostile UA header can't blow up
24
+ * the insert with a row-too-large error.
25
+ * - `Model::withoutAudit(fn() => ...)` lets seeders / migrations / batch
26
+ * imports temporarily disable auditing for bulk writes that would
27
+ * otherwise dispatch hundreds of thousands of jobs.
28
+ */
29
+ import type { GeneratedFile, PhpConfig } from './types.js';
30
+ /** Generate the HasOmnifyAuditLog trait. */
31
+ export declare function generateAuditTrait(config: PhpConfig): GeneratedFile[];
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Generates the HasOmnifyAuditLog trait for models with audit-log opt-in.
3
+ *
4
+ * The trait is the single hook point that connects every audited Eloquent
5
+ * model to the central `audits` history table. Adding `use HasOmnifyAuditLog;`
6
+ * to a model makes it record `created` / `updated` / `deleted` / `restored`
7
+ * events, with dirty-only diffing on update and sensitive-column scrubbing
8
+ * before serialization.
9
+ *
10
+ * Performance design (audit-log is a hot path on every write):
11
+ * - WriteAuditLog is dispatched as a queued job when `omnify.audit.queue`
12
+ * is non-empty. The web request returns immediately; the audit row is
13
+ * inserted by a worker. Sync fallback is only used in dev / low-traffic
14
+ * mode (queue == "").
15
+ * - Auth, URL, IP, and User-Agent are captured at DISPATCH time, not at
16
+ * job HANDLE time — workers have no Auth context and no Request facade.
17
+ * - Updated events skip dispatch entirely when `getDirty()` is empty
18
+ * (re-saves with no real changes don't emit phantom audit rows).
19
+ * - Sensitive columns listed in `$auditExclude` are stripped from
20
+ * `old_values` / `new_values` BEFORE the JSON payload is built, so a
21
+ * stolen audits dump can't leak passwords or two-factor secrets.
22
+ * - String columns are truncated to fit the table schema (`url` 2048,
23
+ * `user_agent` 1023, `tags` 255) so a hostile UA header can't blow up
24
+ * the insert with a row-too-large error.
25
+ * - `Model::withoutAudit(fn() => ...)` lets seeders / migrations / batch
26
+ * imports temporarily disable auditing for bulk writes that would
27
+ * otherwise dispatch hundreds of thousands of jobs.
28
+ */
29
+ import { baseFile, resolveGlobalTraitPath, resolveGlobalTraitNamespace, } from './types.js';
30
+ /** Generate the HasOmnifyAuditLog trait. */
31
+ export function generateAuditTrait(config) {
32
+ const traitsNamespace = resolveGlobalTraitNamespace(config, config.models.baseNamespace + '\\Traits');
33
+ const modelNamespace = config.models.namespace;
34
+ const content = `<?php
35
+
36
+ namespace ${traitsNamespace};
37
+
38
+ /**
39
+ * Trait that records every Eloquent lifecycle event into the central
40
+ * audits history table. Apply via \`use HasOmnifyAuditLog;\` on any model
41
+ * whose schema has \`options.audit.log: true\` (or when the global
42
+ * \`audit.log\` flag is enabled).
43
+ *
44
+ * DO NOT EDIT — auto-generated by Omnify. Any manual changes are lost on
45
+ * the next \`omnify generate\` run.
46
+ *
47
+ * @generated by omnify
48
+ */
49
+
50
+ use ${modelNamespace}\\Audit;
51
+ use Illuminate\\Support\\Facades\\Auth;
52
+ use Illuminate\\Support\\Str;
53
+
54
+ trait HasOmnifyAuditLog
55
+ {
56
+ /**
57
+ * Per-class kill switch flipped by withoutAudit(). Keyed by class so
58
+ * disabling on one model doesn't accidentally silence another running
59
+ * inside the same callback.
60
+ *
61
+ * @var array<class-string, bool>
62
+ */
63
+ protected static array $omnifyAuditingDisabled = [];
64
+
65
+ /**
66
+ * Eloquent boot hook. Wires the four model events into the audit
67
+ * recorder. Restored is gated on a softDelete-capable model; calling
68
+ * \`static::restored\` on a non-softDelete model is a no-op so this is
69
+ * safe across the board.
70
+ */
71
+ public static function bootHasOmnifyAuditLog(): void
72
+ {
73
+ static::created(function ($model) {
74
+ $model->writeOmnifyAudit('created', null, $model->getAttributes());
75
+ });
76
+ static::updated(function ($model) {
77
+ $dirty = $model->getDirty();
78
+ if (empty($dirty)) {
79
+ return;
80
+ }
81
+ $old = array_intersect_key($model->getOriginal(), $dirty);
82
+ $model->writeOmnifyAudit('updated', $old, $dirty);
83
+ });
84
+ static::deleted(function ($model) {
85
+ $event = method_exists($model, 'isForceDeleting') && $model->isForceDeleting()
86
+ ? 'deleted'
87
+ : (method_exists($model, 'trashed') ? 'soft_deleted' : 'deleted');
88
+ $model->writeOmnifyAudit($event, $model->getOriginal(), null);
89
+ });
90
+ if (method_exists(static::class, 'restored')) {
91
+ static::restored(function ($model) {
92
+ $model->writeOmnifyAudit('restored', null, $model->getAttributes());
93
+ });
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Build the audit payload and dispatch it. All Auth / Request capture
99
+ * happens here on the request thread, so the worker only needs to
100
+ * persist the row.
101
+ *
102
+ * @param array<string, mixed>|null $oldValues
103
+ * @param array<string, mixed>|null $newValues
104
+ */
105
+ protected function writeOmnifyAudit(string $event, ?array $oldValues, ?array $newValues): void
106
+ {
107
+ if (static::$omnifyAuditingDisabled[static::class] ?? false) {
108
+ return;
109
+ }
110
+
111
+ $exclude = array_unique(array_merge(
112
+ (array) config('omnify.audit.logExclude', []),
113
+ (array) (static::$auditExclude ?? [])
114
+ ));
115
+ if (!empty($exclude)) {
116
+ $flip = array_flip($exclude);
117
+ if ($oldValues !== null) {
118
+ $oldValues = array_diff_key($oldValues, $flip);
119
+ }
120
+ if ($newValues !== null) {
121
+ $newValues = array_diff_key($newValues, $flip);
122
+ }
123
+ }
124
+
125
+ $request = function_exists('request') ? request() : null;
126
+ $user = Auth::user();
127
+ $tags = static::$auditTags ?? [];
128
+
129
+ $payload = [
130
+ 'auditable_type' => $this->getMorphClass(),
131
+ 'auditable_id' => $this->getKey(),
132
+ 'event' => $event,
133
+ 'old_values' => $oldValues,
134
+ 'new_values' => $newValues,
135
+ 'user_type' => $user ? $user->getMorphClass() : null,
136
+ 'user_id' => $user ? $user->getKey() : null,
137
+ 'url' => $request ? Str::limit($request->fullUrl(), 2048, '') : null,
138
+ 'ip_address' => $request ? $request->ip() : null,
139
+ 'user_agent' => $request ? Str::limit((string) $request->userAgent(), 1023, '') : null,
140
+ 'tags' => !empty($tags) ? Str::limit(implode(',', (array) $tags), 255, '') : null,
141
+ 'created_at' => now(),
142
+ ];
143
+
144
+ $queue = (string) config('omnify.audit.logQueue', '');
145
+ if ($queue !== '') {
146
+ \\App\\Jobs\\WriteAuditLog::dispatch($payload)->onQueue($queue);
147
+ } else {
148
+ \\App\\Jobs\\WriteAuditLog::dispatchSync($payload);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Run \`$callback\` with audit-log dispatch suppressed for THIS model
154
+ * class. Use for seeders, bulk imports, and migration data fixups
155
+ * where you don't want hundreds of thousands of audit rows. Always
156
+ * restores the previous state, even on exception.
157
+ *
158
+ * @template T
159
+ * @param callable(): T $callback
160
+ * @return T
161
+ */
162
+ public static function withoutAudit(callable $callback): mixed
163
+ {
164
+ $prev = static::$omnifyAuditingDisabled[static::class] ?? false;
165
+ static::$omnifyAuditingDisabled[static::class] = true;
166
+ try {
167
+ return $callback();
168
+ } finally {
169
+ static::$omnifyAuditingDisabled[static::class] = $prev;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Direct helper so external code can persist an audit row without
175
+ * going through Eloquent events (e.g. recording a sensitive admin
176
+ * action that's not a model write). Routed through the same job
177
+ * pipeline so retention + queue config still apply.
178
+ *
179
+ * @param array<string, mixed>|null $oldValues
180
+ * @param array<string, mixed>|null $newValues
181
+ */
182
+ public function recordAudit(string $event, ?array $oldValues = null, ?array $newValues = null): void
183
+ {
184
+ $this->writeOmnifyAudit($event, $oldValues, $newValues);
185
+ }
186
+
187
+ /** Eloquent relation back to every audit row that targets this model. */
188
+ public function audits(): \\Illuminate\\Database\\Eloquent\\Relations\\MorphMany
189
+ {
190
+ return $this->morphMany(Audit::class, 'auditable');
191
+ }
192
+ }
193
+ `;
194
+ return [
195
+ baseFile(resolveGlobalTraitPath(config, 'HasOmnifyAuditLog.php', config.models.basePath + '/Traits'), content),
196
+ ];
197
+ }
package/dist/php/index.js CHANGED
@@ -25,6 +25,9 @@ import { generatePolicies } from './policy-generator.js';
25
25
  import { generateFileModels } from './file-model-generator.js';
26
26
  import { generateFileTrait } from './file-trait-generator.js';
27
27
  import { generateFileCleanup } from './file-cleanup-generator.js';
28
+ import { generateAuditTrait } from './audit-trait-generator.js';
29
+ import { generateAuditObserver } from './audit-observer-generator.js';
30
+ import { generateAuditConfig } from './audit-config-generator.js';
28
31
  import { generateSchemaConfig } from './schema-config-generator.js';
29
32
  import { generateTranslatableConfig } from './translatable-config-generator.js';
30
33
  import { baseFile } from './types.js';
@@ -57,6 +60,17 @@ export function generatePhp(data, overrides) {
57
60
  files.push(...generateFileFactory(reader, config));
58
61
  files.push(...generateFileCleanup(reader, config));
59
62
  }
63
+ // Audit-log infrastructure (only when at least one schema opts in,
64
+ // either via global `audit.log: true` or per-schema override).
65
+ // Emits the trait, the queued WriteAuditLog job, the Audit model,
66
+ // and the runtime config that powers them. The audits TABLE itself
67
+ // is emitted by omnify-go (synthesized once globally) — this layer
68
+ // is purely the application-level codegen.
69
+ if (reader.hasAuditLog()) {
70
+ files.push(...generateAuditTrait(config));
71
+ files.push(...generateAuditObserver(reader, config));
72
+ files.push(...generateAuditConfig(reader, config));
73
+ }
60
74
  // CRUD API files (only for schemas with options.api)
61
75
  if (reader.hasApiSchemas()) {
62
76
  files.push(...generateSchemaConfig(reader, config));
@@ -64,11 +64,17 @@ function generateBaseModel(name, schema, reader, config) {
64
64
  const prop = properties[p];
65
65
  return prop && prop['type'] === 'File';
66
66
  });
67
- const imports = buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait, needsUlidTrait, hasNestedSet, config.nestedset.namespace, hasFiles, modelNamespace, localesNamespace, traitsNamespace, sharedModelsNamespace, config);
67
+ // Audit-log opt-in (folded global default + per-schema override).
68
+ // Drives the `use HasOmnifyAuditLog;` import + trait declaration AND
69
+ // the per-schema `$auditExclude` / `$auditTags` static properties on
70
+ // the generated base model.
71
+ const hasAuditLog = reader.isAuditLogEnabled(name);
72
+ const imports = buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait, needsUlidTrait, hasNestedSet, config.nestedset.namespace, hasFiles, modelNamespace, localesNamespace, traitsNamespace, sharedModelsNamespace, config, hasAuditLog);
68
73
  const docProperties = buildDocProperties(properties, expandedProperties, propertyOrder);
69
74
  const baseClass = isAuthenticatable ? 'Authenticatable' : 'BaseModel';
70
75
  const implementsClause = hasTranslatable ? ' implements TranslatableContract' : '';
71
- const traits = buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait, needsUlidTrait, hasNestedSet, hasFiles);
76
+ const traits = buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait, needsUlidTrait, hasNestedSet, hasFiles, hasAuditLog);
77
+ const auditLogProperties = buildAuditLogProperties(name, reader);
72
78
  const fillable = buildFillable(properties, expandedProperties, propertyOrder);
73
79
  const hidden = buildHidden(properties, expandedProperties, propertyOrder);
74
80
  const appends = buildAppends(expandedProperties);
@@ -174,7 +180,7 @@ ${appends} ];
174
180
  return [
175
181
  ${casts} ];
176
182
  }
177
- ${relations}${accessors}${fileAccessors}${auditSection}${nestedSetMethod}
183
+ ${auditLogProperties}${relations}${accessors}${fileAccessors}${auditSection}${nestedSetMethod}
178
184
  }
179
185
  `;
180
186
  return baseFile(resolveModularBasePath(config, name, 'Models', `${modelName}BaseModel.php`, config.models.basePath), content);
@@ -229,7 +235,7 @@ ${traits.join('\n')}
229
235
  `;
230
236
  return userFile(`${config.models.path}/${modelName}.php`, content);
231
237
  }
232
- function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, nestedSetNamespace = 'Aimeos\\Nestedset', hasFiles = false, modelNamespace = '', localesNamespace = '', traitsNamespace = '', sharedModelsNamespace = '', config) {
238
+ function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, nestedSetNamespace = 'Aimeos\\Nestedset', hasFiles = false, modelNamespace = '', localesNamespace = '', traitsNamespace = '', sharedModelsNamespace = '', config, hasAuditLog = false) {
233
239
  const lines = [];
234
240
  // Import BaseModel from shared namespace when in modular mode
235
241
  if (sharedModelsNamespace && sharedModelsNamespace !== baseNamespace && !isAuthenticatable) {
@@ -268,6 +274,16 @@ function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable
268
274
  lines.push(`use ${modelNamespace}\\File;`);
269
275
  }
270
276
  }
277
+ if (hasAuditLog) {
278
+ // HasOmnifyAuditLog is a global Omnify trait emitted by
279
+ // audit-trait-generator.ts. Same global-traits placement as HasFiles
280
+ // so consumers can update the trait in place without per-schema
281
+ // duplication.
282
+ const auditTraitsNs = config
283
+ ? resolveGlobalTraitNamespace(config, baseNamespace + '\\Traits')
284
+ : (traitsNamespace || baseNamespace + '\\Traits');
285
+ lines.push(`use ${auditTraitsNs}\\HasOmnifyAuditLog;`);
286
+ }
271
287
  if (hasSoftDelete) {
272
288
  lines.push('use Illuminate\\Database\\Eloquent\\SoftDeletes;');
273
289
  }
@@ -321,11 +337,13 @@ function buildDocProperties(properties, expandedProperties, propertyOrder) {
321
337
  }
322
338
  return lines.length === 0 ? '' : lines.join('\n') + '\n';
323
339
  }
324
- function buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, hasFiles = false) {
340
+ function buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, hasFiles = false, hasAuditLog = false) {
325
341
  const lines = [];
326
342
  lines.push(' use HasLocalizedDisplayName;');
327
343
  if (hasFiles)
328
344
  lines.push(' use HasFiles;');
345
+ if (hasAuditLog)
346
+ lines.push(' use HasOmnifyAuditLog;');
329
347
  if (needsUuidTrait)
330
348
  lines.push(' use HasUuids;');
331
349
  if (needsUlidTrait)
@@ -730,6 +748,54 @@ function buildNestedSetParentIdMethod(parentColumn) {
730
748
  }
731
749
  `;
732
750
  }
751
+ /**
752
+ * Emits per-schema audit-log static properties on the base model:
753
+ * - `$auditExclude` is the merged scrub list (global defaults +
754
+ * per-schema additions) — read by HasOmnifyAuditLog at write time
755
+ * so sensitive columns are stripped from old_values / new_values
756
+ * BEFORE serialization.
757
+ * - `$auditTags` is the static tag list applied to every audit row
758
+ * from this model; useful for grouping (e.g. ["billing"]).
759
+ *
760
+ * Empty arrays are omitted entirely so the trait falls through to its
761
+ * own static defaults — keeps the generated PHP minimal.
762
+ */
763
+ function buildAuditLogProperties(schemaName, reader) {
764
+ if (!reader.isAuditLogEnabled(schemaName))
765
+ return '';
766
+ const excludes = reader.getAuditExcludesFor(schemaName);
767
+ const tags = reader.getAuditTagsFor(schemaName);
768
+ const parts = [];
769
+ if (excludes.length > 0) {
770
+ const lines = excludes.map((c) => ` ${JSON.stringify(c).replace(/"/g, "'")},`).join('\n');
771
+ parts.push(`
772
+ /**
773
+ * Columns scrubbed from old_values / new_values BEFORE the audit row
774
+ * is built. Merged at runtime with the global \`config('omnify.audit.logExclude')\`
775
+ * defaults — they are NOT a replacement.
776
+ *
777
+ * @var array<int, string>
778
+ */
779
+ protected static array $auditExclude = [
780
+ ${lines}
781
+ ];`);
782
+ }
783
+ if (tags.length > 0) {
784
+ const lines = tags.map((t) => ` ${JSON.stringify(t).replace(/"/g, "'")},`).join('\n');
785
+ parts.push(`
786
+ /**
787
+ * Static tags applied to every audit row from this model. Surfaces
788
+ * in the audits table's \`tags\` column for grouping in the activity
789
+ * feed.
790
+ *
791
+ * @var array<int, string>
792
+ */
793
+ protected static array $auditTags = [
794
+ ${lines}
795
+ ];`);
796
+ }
797
+ return parts.length === 0 ? '' : parts.join('\n') + '\n';
798
+ }
733
799
  function buildAuditSection(options, reader, modelNamespace) {
734
800
  const opts = options;
735
801
  const audit = opts?.['audit'];
@@ -741,7 +807,7 @@ function buildAuditSection(options, reader, modelNamespace) {
741
807
  if (!hasCreatedBy && !hasUpdatedBy && !hasDeletedBy && !hasDefaultOrder)
742
808
  return '';
743
809
  // Get audit model name from schemas.json auditConfig
744
- const auditModel = reader.data?.auditConfig?.model ?? 'User';
810
+ const auditModel = reader.getAuditConfig()?.model ?? 'User';
745
811
  const fqcn = `\\${modelNamespace}\\${auditModel}`;
746
812
  const parts = [];
747
813
  // Model events (booted)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Port of SchemaReader.php — reads schemas.json data.
3
3
  */
4
- import type { SchemasJson, SchemaDefinition, ExpandedProperty, PackageExportInfo } from '../types.js';
4
+ import type { SchemasJson, SchemaDefinition, ExpandedProperty, PackageExportInfo, AuditConfigExport } from '../types.js';
5
5
  export declare class SchemaReader {
6
6
  private data;
7
7
  constructor(data: SchemasJson);
@@ -64,12 +64,32 @@ export declare class SchemaReader {
64
64
  /** Get all schemas that have File-type properties. */
65
65
  getSchemasWithFileProperties(): Record<string, SchemaDefinition>;
66
66
  /** Get global audit config from schemas.json. */
67
- getAuditConfig(): {
68
- model?: string;
69
- createdBy?: boolean;
70
- updatedBy?: boolean;
71
- deletedBy?: boolean;
72
- } | null;
67
+ getAuditConfig(): AuditConfigExport | null;
68
+ /** True when ANY schema (project or package) is audited — the audits
69
+ * table + supporting infra (trait, job, model) are emitted as a unit. */
70
+ hasAuditLog(): boolean;
71
+ /** Resolve per-schema audit-log enablement: per-schema override wins
72
+ * over the global default. Returns false for non-object schemas. */
73
+ isAuditLogEnabled(schemaName: string): boolean;
74
+ /** Object schemas with audit-log enabled (folded global + per-schema). */
75
+ getSchemasWithAuditLog(): Record<string, SchemaDefinition>;
76
+ /** Merged scrub list for a schema (global defaults + per-schema overrides).
77
+ * Duplicates are de-duplicated; order is global-first then per-schema. */
78
+ getAuditExcludesFor(schemaName: string): string[];
79
+ /** Static tag list configured for a schema (or empty). Used by the
80
+ * trait to populate the `tags` column on every audit row. */
81
+ getAuditTagsFor(schemaName: string): string[];
82
+ /** Audit user-model schema name (typically `User`). Required when the
83
+ * feature is enabled — the omnify-go validator already rejects
84
+ * `audit.log: true` without a model, so this returns "" only when the
85
+ * feature is itself off. */
86
+ getAuditUserModel(): string;
87
+ /** Laravel queue name for WriteAuditLog dispatch. Empty string means
88
+ * sync fallback (low-traffic / dev mode). */
89
+ getAuditQueue(): string;
90
+ /** Prunable retention period (e.g. `90d`). Empty string means audits
91
+ * are kept forever — no Prunable scope is registered. */
92
+ getAuditRetention(): string;
73
93
  /** Get schemas that have service options configured (options.service). */
74
94
  getSchemasWithService(): Record<string, import('../types.js').SchemaDefinition>;
75
95
  /** Check if any schema has service options. */
@@ -214,6 +214,102 @@ export class SchemaReader {
214
214
  getAuditConfig() {
215
215
  return this.data.auditConfig ?? null;
216
216
  }
217
+ // ---------------------------------------------------------------------------
218
+ // Audit-log feature helpers (audits-table history)
219
+ //
220
+ // The audit-log feature is OFF unless either (a) the global flag
221
+ // `auditConfig.log === true` is set, or (b) at least one schema has
222
+ // `options.audit.log === true`. The trait, observer job, and Audit model
223
+ // are emitted as a unit — gated by `hasAuditLog()`. Per-schema state is
224
+ // resolved by `isAuditLogEnabled(name)` which folds the global flag and
225
+ // the per-schema override into a single boolean.
226
+ // ---------------------------------------------------------------------------
227
+ /** True when ANY schema (project or package) is audited — the audits
228
+ * table + supporting infra (trait, job, model) are emitted as a unit. */
229
+ hasAuditLog() {
230
+ const cfg = this.getAuditConfig();
231
+ if (cfg?.log === true) {
232
+ // Global on, but the feature still requires at least one auditable
233
+ // OBJECT schema (no point emitting infra for a project that has only
234
+ // enums / partials).
235
+ for (const schema of Object.values(this.getObjectSchemas())) {
236
+ if (schema.options?.audit?.log !== false)
237
+ return true;
238
+ }
239
+ // Global on but every schema explicitly opts out — feature is dead.
240
+ return false;
241
+ }
242
+ for (const schema of Object.values(this.getObjectSchemas())) {
243
+ if (schema.options?.audit?.log === true)
244
+ return true;
245
+ }
246
+ return false;
247
+ }
248
+ /** Resolve per-schema audit-log enablement: per-schema override wins
249
+ * over the global default. Returns false for non-object schemas. */
250
+ isAuditLogEnabled(schemaName) {
251
+ const schema = this.getSchema(schemaName);
252
+ if (!schema)
253
+ return false;
254
+ const kind = schema.kind ?? 'object';
255
+ if (kind !== 'object')
256
+ return false;
257
+ const perSchema = schema.options?.audit?.log;
258
+ if (perSchema === true)
259
+ return true;
260
+ if (perSchema === false)
261
+ return false;
262
+ return this.getAuditConfig()?.log === true;
263
+ }
264
+ /** Object schemas with audit-log enabled (folded global + per-schema). */
265
+ getSchemasWithAuditLog() {
266
+ const result = {};
267
+ for (const [name, schema] of Object.entries(this.getObjectSchemas())) {
268
+ if (this.isAuditLogEnabled(name)) {
269
+ result[name] = schema;
270
+ }
271
+ }
272
+ return result;
273
+ }
274
+ /** Merged scrub list for a schema (global defaults + per-schema overrides).
275
+ * Duplicates are de-duplicated; order is global-first then per-schema. */
276
+ getAuditExcludesFor(schemaName) {
277
+ const schema = this.getSchema(schemaName);
278
+ const global = this.getAuditConfig()?.logExclude ?? [];
279
+ const local = schema?.options?.audit?.logExclude ?? [];
280
+ const seen = new Set();
281
+ const out = [];
282
+ for (const c of [...global, ...local]) {
283
+ if (!seen.has(c)) {
284
+ seen.add(c);
285
+ out.push(c);
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+ /** Static tag list configured for a schema (or empty). Used by the
291
+ * trait to populate the `tags` column on every audit row. */
292
+ getAuditTagsFor(schemaName) {
293
+ const schema = this.getSchema(schemaName);
294
+ return [...(schema?.options?.audit?.logTags ?? [])];
295
+ }
296
+ /** Audit user-model schema name (typically `User`). Required when the
297
+ * feature is enabled — the omnify-go validator already rejects
298
+ * `audit.log: true` without a model, so this returns "" only when the
299
+ * feature is itself off. */
300
+ getAuditUserModel() {
301
+ return this.getAuditConfig()?.model ?? '';
302
+ }
303
+ /** Laravel queue name for WriteAuditLog dispatch. Empty string means
304
+ * sync fallback (low-traffic / dev mode). */
305
+ getAuditQueue() {
306
+ return this.getAuditConfig()?.logQueue ?? '';
307
+ }
308
+ /** Prunable retention period (e.g. `90d`). Empty string means audits
309
+ * are kept forever — no Prunable scope is registered. */
310
+ getAuditRetention() {
311
+ return this.getAuditConfig()?.logRetention ?? '';
312
+ }
217
313
  /** Get schemas that have service options configured (options.service). */
218
314
  getSchemasWithService() {
219
315
  const result = {};
package/dist/types.d.ts CHANGED
@@ -14,6 +14,36 @@ export interface FileConfigExport {
14
14
  readonly purgeSchedule?: string;
15
15
  readonly defaultDisk?: string;
16
16
  }
17
+ /**
18
+ * Global audit configuration surfaced from the Go core.
19
+ *
20
+ * `model` is the FQ schema name of the user / actor (typically `User`)
21
+ * — the audits table records `(user_type, user_id)` polymorphically so
22
+ * any model can be the actor, but the trait needs a default to capture
23
+ * `Auth::user()` correctly.
24
+ *
25
+ * `log` toggles the `audits` history table feature globally; per-schema
26
+ * `options.audit.log` overrides per-schema. Sensitive columns listed in
27
+ * `logExclude` are scrubbed from `old_values` / `new_values` BEFORE the
28
+ * row is written, so a stolen audits dump cannot leak passwords.
29
+ *
30
+ * `logRetention` is the Prunable retention period (`90d`, `12w`, `6m`,
31
+ * `1y`); empty means audits are kept forever (no scheduled prune).
32
+ *
33
+ * `logQueue` is the Laravel queue connection name. Empty string means
34
+ * sync dispatch (low-traffic / dev fallback) — audited writes still
35
+ * complete immediately, just on the request thread.
36
+ */
37
+ export interface AuditConfigExport {
38
+ readonly model?: string;
39
+ readonly createdBy?: boolean;
40
+ readonly updatedBy?: boolean;
41
+ readonly deletedBy?: boolean;
42
+ readonly log?: boolean;
43
+ readonly logExclude?: readonly string[];
44
+ readonly logRetention?: string;
45
+ readonly logQueue?: string;
46
+ }
17
47
  /** Top-level schemas.json structure. */
18
48
  export interface SchemasJson {
19
49
  /**
@@ -39,12 +69,7 @@ export interface SchemasJson {
39
69
  readonly enums: Record<string, string[]>;
40
70
  };
41
71
  readonly fileConfig?: FileConfigExport;
42
- readonly auditConfig?: {
43
- readonly model?: string;
44
- readonly createdBy?: boolean;
45
- readonly updatedBy?: boolean;
46
- readonly deletedBy?: boolean;
47
- };
72
+ readonly auditConfig?: AuditConfigExport;
48
73
  readonly packages?: Record<string, PackageExportInfo>;
49
74
  readonly schemas: Record<string, SchemaDefinition>;
50
75
  }
@@ -158,9 +183,18 @@ export interface SchemaOptions {
158
183
  readonly unique?: readonly unknown[];
159
184
  readonly api?: ApiOptions;
160
185
  readonly audit?: {
186
+ readonly model?: string;
161
187
  readonly createdBy?: boolean;
162
188
  readonly updatedBy?: boolean;
163
189
  readonly deletedBy?: boolean;
190
+ /** Per-schema opt-in to the audits history feature. Pointer-tristate
191
+ * semantics: undefined = inherit global; true/false = explicit. */
192
+ readonly log?: boolean;
193
+ /** Per-schema scrub list. Merged with the global `auditConfig.logExclude`
194
+ * before any audit row is written. */
195
+ readonly logExclude?: readonly string[];
196
+ /** Static tag list applied to every audit row from this schema. */
197
+ readonly logTags?: readonly string[];
164
198
  };
165
199
  /**
166
200
  * Service layer codegen options (issue #57).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "5.0.5",
3
+ "version": "5.1.0",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",