fresco 0.0.1

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/exe/fresco +3 -0
  3. data/lib/fresco/application.rb +12 -0
  4. data/lib/fresco/cli/build.rb +682 -0
  5. data/lib/fresco/cli/dev.rb +17 -0
  6. data/lib/fresco/cli/dev_loop.rb +815 -0
  7. data/lib/fresco/cli/new.rb +120 -0
  8. data/lib/fresco/cli/release.rb +76 -0
  9. data/lib/fresco/cli.rb +56 -0
  10. data/lib/fresco/database_config.rb +34 -0
  11. data/lib/fresco/generators/app/Gemfile.tt +18 -0
  12. data/lib/fresco/generators/app/README.md.tt +32 -0
  13. data/lib/fresco/generators/app/app/action.rb.tt +20 -0
  14. data/lib/fresco/generators/app/app/actions/root_path.rb.tt +5 -0
  15. data/lib/fresco/generators/app/app/views/layouts/application.html.erb +29 -0
  16. data/lib/fresco/generators/app/app/views/root_path.html.erb +8 -0
  17. data/lib/fresco/generators/app/app.rb.tt +15 -0
  18. data/lib/fresco/generators/app/bin/build +2 -0
  19. data/lib/fresco/generators/app/bin/dev +2 -0
  20. data/lib/fresco/generators/app/bin/release +2 -0
  21. data/lib/fresco/generators/app/config/app.rb.tt +26 -0
  22. data/lib/fresco/generators/app/config/database.rb +17 -0
  23. data/lib/fresco/generators/app/config/routes.rb +11 -0
  24. data/lib/fresco/generators/app/db/schema.rb +14 -0
  25. data/lib/fresco/generators/app/public/404.html +87 -0
  26. data/lib/fresco/generators/app/public/500.html +84 -0
  27. data/lib/fresco/migration_builder.rb +55 -0
  28. data/lib/fresco/model_builder.rb +54 -0
  29. data/lib/fresco/paths.rb +20 -0
  30. data/lib/fresco/router.rb +67 -0
  31. data/lib/fresco/runtime/boot.rb +34 -0
  32. data/lib/fresco/runtime/db_postgres.rb +403 -0
  33. data/lib/fresco/runtime/db_sqlite.rb +495 -0
  34. data/lib/fresco/runtime/http.c +456 -0
  35. data/lib/fresco/runtime/postgres.c +339 -0
  36. data/lib/fresco/runtime/runtime.rb +1810 -0
  37. data/lib/fresco/runtime/sqlite.c +220 -0
  38. data/lib/fresco/runtime/welcome.rb +152 -0
  39. data/lib/fresco/schema_builder.rb +71 -0
  40. data/lib/fresco/templates/dispatch.rb.erb +32 -0
  41. data/lib/fresco/templates/layout_dispatch.rb.erb +16 -0
  42. data/lib/fresco/templates/manifest.rb.erb +5 -0
  43. data/lib/fresco/templates/migrations.rb.erb +152 -0
  44. data/lib/fresco/templates/model.rb.erb +223 -0
  45. data/lib/fresco/templates/view.rb.erb +5 -0
  46. data/lib/fresco/version.rb +3 -0
  47. data/lib/fresco.rb +61 -0
  48. metadata +115 -0
@@ -0,0 +1,495 @@
1
+ # Fresco SQLite adapter. Copied verbatim to generated/db_adapter.rb
2
+ # by `fresco build` when config/database.rb declares `Fresco.database
3
+ # :sqlite`. Always loaded between runtime.rb and config/app.rb — boot
4
+ # order in lib/fresco/runtime/boot.rb.
5
+ #
6
+ # Contains everything an action needs to talk to SQLite under both
7
+ # CRuby (dev) and Spinel (release): the FFI module pointing at
8
+ # generated/runtime/sqlite.c, the Ruby wrapper, and the
9
+ # `Fresco::Db::Active` alias that the M3+ codegen will target.
10
+ #
11
+ # The `Sqlite` module's ffi_cflags/ffi_lib pull
12
+ # generated/runtime/sqlite.c into Spinel's cc invocation and
13
+ # `-lsqlite3` into the link line. Under CRuby the same declarations
14
+ # are no-ops (`fresco dev` stubs them on Module) and dev installs
15
+ # singleton-method stand-ins that route through the `sqlite3` gem.
16
+ module Sqlite
17
+ ffi_cflags "generated/runtime/sqlite.c"
18
+ ffi_lib "sqlite3"
19
+
20
+ ffi_func :fresco_sqlite_open, [:str], :int
21
+ ffi_func :fresco_sqlite_close, [:int], :int
22
+ ffi_func :fresco_sqlite_exec, [:int, :str], :int
23
+ ffi_func :fresco_sqlite_prepare, [:int, :str], :int
24
+ ffi_func :fresco_sqlite_bind_str, [:int, :int, :str], :int
25
+ ffi_func :fresco_sqlite_bind_int, [:int, :int, :int], :int
26
+ ffi_func :fresco_sqlite_step, [:int], :int
27
+ ffi_func :fresco_sqlite_col_str, [:int, :int], :str
28
+ ffi_func :fresco_sqlite_col_int, [:int, :int], :int
29
+ ffi_func :fresco_sqlite_col_count, [:int], :int
30
+ ffi_func :fresco_sqlite_finalize, [:int], :int
31
+ ffi_func :fresco_sqlite_reset, [:int], :int
32
+ ffi_func :fresco_sqlite_last_insert_rowid, [:int], :int
33
+ end
34
+
35
+ module Fresco
36
+ module Db
37
+ # Thin Ruby cover over the Sqlite FFI module. Methods return values
38
+ # straight from the C side — handle ids as Int, cursor ids as Int,
39
+ # status codes as Int (0 success, -1 error), column values as Str
40
+ # or Int. No exceptions on DB errors; callers branch on `< 0`.
41
+ #
42
+ # Spinel-shape constraints worth flagging up front:
43
+ # - Attribute is named `dbh` (not `handle`) — the latter widens
44
+ # poly-dispatch return types across classes that also define
45
+ # `handle` (see Tep's same workaround). Keeps every method
46
+ # name on this class collision-free across the framework.
47
+ # - `cursor_id` is returned by #prepare and threaded through
48
+ # #step/#bind_*/#col_*/#finalize/#reset. The single-cursor
49
+ # pattern works too — just call them with one cursor id at a
50
+ # time — but the API allows several open at once (M3+ codegen
51
+ # stacks finders inside each other).
52
+ # - Defaults on every typed parameter pin Spinel's analyzer to
53
+ # the right slot type — same rule as Params/Response in this
54
+ # file. Required-but-defaultless params collapse to mrb_int
55
+ # under Spinel (see [[spinel_initialize_kwargs]]).
56
+ class SQLite
57
+ attr_accessor :dbh
58
+
59
+ def initialize
60
+ @dbh = -1
61
+ end
62
+
63
+ # Returns true on success, false on failure. Path may be a real
64
+ # file or `:memory:` for an anonymous in-memory database. Calling
65
+ # #open twice on the same instance leaks the prior handle — close
66
+ # first.
67
+ def open(path = "")
68
+ h = Sqlite.fresco_sqlite_open(path)
69
+ return false if h < 0
70
+ @dbh = h
71
+ true
72
+ end
73
+
74
+ def close
75
+ if @dbh >= 0
76
+ Sqlite.fresco_sqlite_close(@dbh)
77
+ @dbh = -1
78
+ end
79
+ 0
80
+ end
81
+
82
+ # Run a no-bind statement (DDL, BEGIN/COMMIT, queries with literal
83
+ # constants). Returns true on success. For any user-supplied value
84
+ # always use prepare + bind + step + finalize.
85
+ def exec(sql = "")
86
+ return false if @dbh < 0
87
+ Sqlite.fresco_sqlite_exec(@dbh, sql) == 0
88
+ end
89
+
90
+ # Open a cursor on a parameterised statement. Returns an Int
91
+ # cursor id (>= 1) on success, -1 on failure. Subsequent bind_str/
92
+ # bind_int/step/col_str/col_int/col_count/finalize/reset calls
93
+ # take this id as their first argument. Always pair with #finalize.
94
+ def prepare(sql = "")
95
+ return -1 if @dbh < 0
96
+ Sqlite.fresco_sqlite_prepare(@dbh, sql)
97
+ end
98
+
99
+ def bind_str(cid = 0, idx = 0, value = "")
100
+ Sqlite.fresco_sqlite_bind_str(cid, idx, value)
101
+ end
102
+
103
+ def bind_int(cid = 0, idx = 0, value = 0)
104
+ Sqlite.fresco_sqlite_bind_int(cid, idx, value)
105
+ end
106
+
107
+ # 1 -> row available, 0 -> done, -1 -> error or invalid cursor.
108
+ def step(cid = 0); Sqlite.fresco_sqlite_step(cid); end
109
+ def col_str(cid = 0, idx = 0); Sqlite.fresco_sqlite_col_str(cid, idx); end
110
+ def col_int(cid = 0, idx = 0); Sqlite.fresco_sqlite_col_int(cid, idx); end
111
+ def col_count(cid = 0); Sqlite.fresco_sqlite_col_count(cid); end
112
+ def finalize(cid = 0); Sqlite.fresco_sqlite_finalize(cid); end
113
+ def reset(cid = 0); Sqlite.fresco_sqlite_reset(cid); end
114
+
115
+ def last_rowid
116
+ return -1 if @dbh < 0
117
+ Sqlite.fresco_sqlite_last_insert_rowid(@dbh)
118
+ end
119
+
120
+ # Convenience: prepare a single-row single-column query with at
121
+ # most one Str bind, step once, return col 0 as Str, finalize.
122
+ # Pass `""` for no bind. Mirrors Tep::SQLite#first_str so M1
123
+ # smoke tests stay terse.
124
+ def first_str(sql = "", p1 = "")
125
+ return "" if @dbh < 0
126
+ cid = Sqlite.fresco_sqlite_prepare(@dbh, sql)
127
+ return "" if cid < 0
128
+ if p1.length > 0
129
+ Sqlite.fresco_sqlite_bind_str(cid, 1, p1)
130
+ end
131
+ result = ""
132
+ if Sqlite.fresco_sqlite_step(cid) == 1
133
+ result = Sqlite.fresco_sqlite_col_str(cid, 0)
134
+ end
135
+ Sqlite.fresco_sqlite_finalize(cid)
136
+ result
137
+ end
138
+
139
+ def first_int(sql = "", p1 = "")
140
+ return 0 if @dbh < 0
141
+ cid = Sqlite.fresco_sqlite_prepare(@dbh, sql)
142
+ return 0 if cid < 0
143
+ if p1.length > 0
144
+ Sqlite.fresco_sqlite_bind_str(cid, 1, p1)
145
+ end
146
+ result = 0
147
+ if Sqlite.fresco_sqlite_step(cid) == 1
148
+ result = Sqlite.fresco_sqlite_col_int(cid, 0)
149
+ end
150
+ Sqlite.fresco_sqlite_finalize(cid)
151
+ result
152
+ end
153
+ end
154
+
155
+ # Alias the chosen adapter so generated code (M3+) and user actions
156
+ # can refer to one name regardless of which is linked.
157
+ #
158
+ # Don't call `Fresco::Db::Active.new` directly from action code:
159
+ # Spinel's analyzer doesn't chase constant aliases through Active,
160
+ # treats the receiver as int, and emits 0 for every subsequent
161
+ # method call on the returned object. Use `Fresco::Db.new_instance`
162
+ # below instead — the typed factory gives Spinel a concrete class
163
+ # to bind dispatch against.
164
+ Active = SQLite
165
+
166
+ # Adapter-agnostic factory. Returns a fresh unopened SQLite (or
167
+ # Postgres, in the other template) instance. Action code that
168
+ # wants to be DB-agnostic calls this; Spinel's analyzer sees the
169
+ # concrete return type per-build and dispatches correctly.
170
+ def self.new_instance
171
+ SQLite.new
172
+ end
173
+
174
+ # Lazy connection handle for the generated model layer. Held as
175
+ # an Int (the slot-table index returned by fresco_sqlite_open),
176
+ # NOT as a wrapper instance — module-level `@x = SQLite.new` at
177
+ # load time tripped Spinel's static-initializer requirement; an
178
+ # int default sidesteps it entirely. Initialised to -1 ("not
179
+ # opened yet"); ensure_connection_dbh! lazy-opens on first model
180
+ # call and re-uses the slot for the process lifetime. M5 replaces
181
+ # this with explicit per-worker boot!/shutdown! plumbing.
182
+ @conn_dbh = -1
183
+
184
+ def self.ensure_connection_dbh!
185
+ if @conn_dbh < 0
186
+ h = Sqlite.fresco_sqlite_open(Fresco.database_path)
187
+ @conn_dbh = h if h > 0
188
+ end
189
+ @conn_dbh
190
+ end
191
+
192
+ # SQL query logging. Off by default — set FRESCO_LOG_SQL to any
193
+ # non-empty value to enable (mirrors the NO_COLOR convention used
194
+ # by Color.enabled? in runtime.rb).
195
+ #
196
+ # Output format — lines are nested under the [request] log line by
197
+ # buffering during the request and flushing after log_request_full
198
+ # prints. With bindings captured at bind_int/bind_str call time,
199
+ # the rendered line looks like:
200
+ #
201
+ # [request] GET /users 200 (1234us)
202
+ # [db cached] SELECT id, name FROM users WHERE id = ? [$1=5]
203
+ #
204
+ # Buffering uses a single "current pending entry" (per-module, not
205
+ # per-cursor) — codegen always sequences `prepare → bind* → step`
206
+ # without interleaving, so the latest pending row is the right one
207
+ # to attribute binds to. Each new prepare/exec flushes the prior
208
+ # pending entry to the buffer first, so nothing is lost even if a
209
+ # caller does two prepares back-to-back without intervening steps.
210
+ #
211
+ # Outside a request (migrations, db:migrate, db:rollback) buffering
212
+ # is off — lines print directly so they interleave naturally with
213
+ # the [migrate] progress lines. The runtime toggles `@in_request`
214
+ # via begin_request! / flush_log! around each request.
215
+ def self.log_sql?
216
+ ENV.fetch("FRESCO_LOG_SQL", "") != ""
217
+ end
218
+
219
+ # In-request buffering state. `@in_request` flips on at the start
220
+ # of each request via begin_request! and back off when flush_log!
221
+ # drains the buffer. `@log_buffer` is seeded with a placeholder
222
+ # entry that's immediately deleted — the same seed-and-clear trick
223
+ # the rest of runtime.rb uses to pin an Array<String> shape under
224
+ # Spinel's analyzer (a bare `[]` would lower to a poly-typed array
225
+ # and the first push would widen each entry to RbVal).
226
+ @in_request = false
227
+ @log_buffer = [""]
228
+ @log_buffer.delete_at(0)
229
+
230
+ # `@has_pending` is the timing-state flag: true between a
231
+ # prepare/cached_prepare and the first step that follows. We need
232
+ # it independent of `@pending_sql` because timing runs even when
233
+ # FRESCO_LOG_SQL is off (so @pending_sql may be empty), but the
234
+ # prepare→step bracket still needs to be measured for the
235
+ # request-line roll-up.
236
+ @has_pending = false
237
+ @pending_t0 = 0
238
+ @pending_sql = ""
239
+ @pending_label = ""
240
+ @pending_binds = ""
241
+
242
+ # The per-request SQL totals (`@db_sql_total_micros`,
243
+ # `@db_sql_count`) live on the `Fresco` module in runtime.rb,
244
+ # not here. That's the workaround for the Spinel cross-file-
245
+ # value-return collapse — see the comment on those counters in
246
+ # runtime.rb. Below we call `Fresco.add_sql_micros!` (side-effect
247
+ # cross-file call, works fine) and `Fresco.reset_db_sql_stats!`
248
+ # (same).
249
+ def self.begin_request!
250
+ @in_request = true
251
+ Fresco.reset_db_sql_stats!
252
+ end
253
+
254
+ # Called from the request handler after log_request_full prints.
255
+ # Drains any pending entry (covering queries that prepared but
256
+ # never reached step — rare, but possible if a handler raises
257
+ # mid-bind), then dumps every buffered line indented under the
258
+ # [request] line. Resets state for the next request.
259
+ #
260
+ # NB: clear the buffer in place rather than re-assigning the
261
+ # ivar. Reassigning `@log_buffer = [""]; @log_buffer.delete_at(0)`
262
+ # every request orphans the old array; Spinel doesn't reclaim it
263
+ # and a few hundred requests in we get a bus error on the next
264
+ # access. delete_at-from-the-tail is a safe in-place reset —
265
+ # `sp_StrArray_delete_at` is a slot pop, no realloc.
266
+ def self.flush_log!
267
+ flush_pending!
268
+ i = 0
269
+ while i < @log_buffer.length
270
+ puts @log_buffer[i]
271
+ i += 1
272
+ end
273
+ while @log_buffer.length > 0
274
+ @log_buffer.delete_at(@log_buffer.length - 1)
275
+ end
276
+ @in_request = false
277
+ end
278
+
279
+ # Emit the current pending entry (if any) and clear it. Two jobs:
280
+ # 1. Compute the [prepare → now] duration and fold it into the
281
+ # per-request totals. Runs even when FRESCO_LOG_SQL is off so
282
+ # the request-line roll-up stays accurate.
283
+ # 2. When FRESCO_LOG_SQL is on, emit the formatted log line with
284
+ # that duration, the column-binding annotation, and the
285
+ # [db] / [db cached] marker.
286
+ # Used as a boundary between consecutive prepares (so a prepare-
287
+ # without-step still gets timed and surfaced) and as the bind→step
288
+ # hand-off (so step's emission picks up the accumulated binds).
289
+ def self.flush_pending!
290
+ return unless @has_pending
291
+ dt = Sock.sphttp_elapsed_micros - @pending_t0
292
+ Fresco.add_sql_micros!(dt)
293
+ if @pending_sql != ""
294
+ line = @pending_label + Color.dim("(" + fmt_micros(dt) + ") ") + color_sql(@pending_sql)
295
+ if @pending_binds != ""
296
+ line += Color.dim(" [" + @pending_binds + "]")
297
+ end
298
+ emit(line)
299
+ end
300
+ @has_pending = false
301
+ @pending_t0 = 0
302
+ @pending_sql = ""
303
+ @pending_label = ""
304
+ @pending_binds = ""
305
+ end
306
+
307
+ # Single-line emit. Routes through the buffer when we're inside a
308
+ # request (so the line lands under the [request] log), or straight
309
+ # to stdout for migrations / one-off CLI work where there's no
310
+ # request to nest under.
311
+ def self.emit(line = "")
312
+ if @in_request
313
+ @log_buffer.push(line)
314
+ else
315
+ puts line
316
+ end
317
+ end
318
+
319
+ # Record one bind on the current pending entry. When `name` is
320
+ # non-empty, use it as the label (`email="bob"`); when empty, fall
321
+ # back to the placeholder index (`$1="bob"`). Model codegen passes
322
+ # the column name through; ad-hoc `Db.exec` / `Model.query` / hand-
323
+ # written SQL paths call without a name and get the `$N` form.
324
+ # Strings get double-quoted so a numeric-looking string ("42")
325
+ # isn't ambiguous with the int 42 when reading logs.
326
+ def self.record_bind_int(idx = 0, value = 0, name = "")
327
+ return if @pending_sql == ""
328
+ sep = @pending_binds == "" ? "" : ", "
329
+ label = name == "" ? "$" + idx.to_s : name
330
+ @pending_binds = @pending_binds + sep + label + "=" + value.to_s
331
+ end
332
+
333
+ def self.record_bind_str(idx = 0, value = "", name = "")
334
+ return if @pending_sql == ""
335
+ sep = @pending_binds == "" ? "" : ", "
336
+ label = name == "" ? "$" + idx.to_s : name
337
+ @pending_binds = @pending_binds + sep + label + "=\"" + value + "\""
338
+ end
339
+
340
+ # Adapter-agnostic surface for the model codegen. Each generated
341
+ # `User.find` / `User.insert` etc. calls these (rather than the
342
+ # wrapper class) so the same model source runs against either
343
+ # backend — only the SQL strings differ (and those are emitted
344
+ # by bin/build at model-codegen time).
345
+ def self.exec(sql = "")
346
+ h = ensure_connection_dbh!
347
+ return false if h < 0
348
+ flush_pending! if @has_pending
349
+ t0 = Sock.sphttp_elapsed_micros
350
+ result = Sqlite.fresco_sqlite_exec(h, sql) == 0
351
+ dt = Sock.sphttp_elapsed_micros - t0
352
+ Fresco.add_sql_micros!(dt)
353
+ if log_sql?
354
+ emit(Color.dim(" [db] (" + fmt_micros(dt) + ") ") + color_sql(sql))
355
+ end
356
+ result
357
+ end
358
+
359
+ def self.prepare(sql = "")
360
+ h = ensure_connection_dbh!
361
+ return -1 if h < 0
362
+ flush_pending! if @has_pending
363
+ @pending_t0 = Sock.sphttp_elapsed_micros
364
+ @has_pending = true
365
+ if log_sql?
366
+ @pending_sql = sql
367
+ @pending_label = Color.dim(" [db] ")
368
+ @pending_binds = ""
369
+ end
370
+ Sqlite.fresco_sqlite_prepare(h, sql)
371
+ end
372
+
373
+ # Prepared-statement cache. Seed-and-clear pins the hash to
374
+ # StrIntHash for Spinel's analyzer — sql string → cursor id.
375
+ # Generated model methods call `cached_prepare` and skip the
376
+ # matching `finalize`; the cursor lives for the process lifetime
377
+ # and gets reset (rewound + bindings preserved) on subsequent
378
+ # calls. Ad-hoc callers writing one-shot queries still use the
379
+ # plain `prepare` / `finalize` pair below.
380
+ @stmt_cache = { "__t" => 0 }
381
+ @stmt_cache.delete("__t")
382
+
383
+ def self.cached_prepare(sql = "")
384
+ if @stmt_cache.key?(sql)
385
+ cid = @stmt_cache[sql]
386
+ Sqlite.fresco_sqlite_reset(cid)
387
+ flush_pending! if @has_pending
388
+ @pending_t0 = Sock.sphttp_elapsed_micros
389
+ @has_pending = true
390
+ if log_sql?
391
+ @pending_sql = sql
392
+ @pending_label = Color.dim(" [db cached] ")
393
+ @pending_binds = ""
394
+ end
395
+ return cid
396
+ end
397
+ cid = prepare(sql)
398
+ @stmt_cache[sql] = cid if cid > 0
399
+ cid
400
+ end
401
+
402
+ # The trailing `name = ""` is the column-name annotation for log
403
+ # output — codegen passes it from the schema, ad-hoc callers omit
404
+ # it. The FFI delegate ignores it; it's purely for the log layer.
405
+ def self.bind_str(cid = 0, idx = 0, value = "", name = "")
406
+ record_bind_str(idx, value, name) if log_sql?
407
+ Sqlite.fresco_sqlite_bind_str(cid, idx, value)
408
+ end
409
+
410
+ def self.bind_int(cid = 0, idx = 0, value = 0, name = "")
411
+ record_bind_int(idx, value, name) if log_sql?
412
+ Sqlite.fresco_sqlite_bind_int(cid, idx, value)
413
+ end
414
+
415
+ # Two timing paths:
416
+ # - First step after a prepare/cached_prepare: flush_pending!
417
+ # computes `dt = now - @pending_t0` (covers prepare + binds
418
+ # + this step) and folds it into the per-request totals.
419
+ # - Iteration step on an already-flushed cursor (SELECT row
420
+ # pump): time just this call and accumulate. A 1000-row
421
+ # SELECT counts every row's micros toward the request total,
422
+ # not just the first.
423
+ def self.step(cid = 0)
424
+ if @has_pending
425
+ result = Sqlite.fresco_sqlite_step(cid)
426
+ flush_pending!
427
+ return result
428
+ end
429
+ t0 = Sock.sphttp_elapsed_micros
430
+ result = Sqlite.fresco_sqlite_step(cid)
431
+ Fresco.add_sql_micros!(Sock.sphttp_elapsed_micros - t0)
432
+ result
433
+ end
434
+
435
+ def self.col_str(cid = 0, idx = 0); Sqlite.fresco_sqlite_col_str(cid, idx); end
436
+ def self.col_int(cid = 0, idx = 0); Sqlite.fresco_sqlite_col_int(cid, idx); end
437
+ def self.finalize(cid = 0); Sqlite.fresco_sqlite_finalize(cid); end
438
+ def self.reset(cid = 0); Sqlite.fresco_sqlite_reset(cid); end
439
+
440
+ def self.last_rowid
441
+ h = ensure_connection_dbh!
442
+ return -1 if h < 0
443
+ Sqlite.fresco_sqlite_last_insert_rowid(h)
444
+ end
445
+
446
+ # Eager-open the connection. Called once from Fresco::App#run at
447
+ # startup. ensure_connection_dbh! already lazy-opens on first
448
+ # use, but eager-open surfaces a bad DSN at process start
449
+ # instead of inside the first request — cheaper to fail there.
450
+ def self.boot!
451
+ ensure_connection_dbh!
452
+ end
453
+
454
+ # Transaction support. The block runs between BEGIN and COMMIT;
455
+ # set `Fresco::Db.rollback!` inside the block to force a
456
+ # ROLLBACK instead. We don't rely on Ruby `rescue` for rollback-
457
+ # on-exception — Spinel's rescue handling across FFI boundaries
458
+ # is iffy, and the explicit-opt-in shape is what the plan
459
+ # specifies. Returns true on commit, false on rollback.
460
+ @tx_rollback_requested = false
461
+
462
+ def self.rollback!
463
+ @tx_rollback_requested = true
464
+ end
465
+
466
+ # Transaction primitives. Spinel doesn't lower custom `&block`
467
+ # parameters cleanly (see the `each(&blk)` note in runtime.rb's
468
+ # Params class), so we expose explicit begin/commit/rollback
469
+ # instead of a block-yielding `transaction { ... }`. Pattern:
470
+ #
471
+ # Fresco::Db.begin_tx
472
+ # User.insert(...)
473
+ # Fresco::Db.rollback! if some_condition
474
+ # Fresco::Db.commit_tx
475
+ #
476
+ # commit_tx checks the rollback flag and emits ROLLBACK instead
477
+ # of COMMIT if it's set. Returns true on commit, false on
478
+ # rollback so callers can branch on the outcome.
479
+ def self.begin_tx
480
+ exec("BEGIN")
481
+ @tx_rollback_requested = false
482
+ true
483
+ end
484
+
485
+ def self.commit_tx
486
+ if @tx_rollback_requested
487
+ exec("ROLLBACK")
488
+ @tx_rollback_requested = false
489
+ return false
490
+ end
491
+ exec("COMMIT")
492
+ true
493
+ end
494
+ end
495
+ end