/__w/shurbej/shurbej/_build/test/cover/ct/shurbej_db.html

1 -module(shurbej_db).
2 -include("shurbej_records.hrl").
3 -include_lib("stdlib/include/ms_transform.hrl").
4
5 -export([
6 %% Libraries
7 get_library/1, ensure_library/1, update_library_version/2,
8 %% Users
9 create_user/3, authenticate_user/2, get_user/1, get_user_by_id/1, delete_key/1,
10 hash_password/2,
11 %% API keys
12 verify_key/1, get_key_info/1, create_key/3, has_any_key/0,
13 %% Items
14 get_item/2, list_items/2, list_item_versions/2, write_item/1, mark_item_deleted/3,
15 list_items_top/2, list_items_trash/2, list_items_children/3, list_items_in_collection/3,
16 count_item_children/1,
17 %% Collection index
18 set_item_collections/3, delete_item_collections/2,
19 %% Collections
20 get_collection/2, list_collections/2, list_collection_versions/2,
21 list_collections_top/2, list_subcollections/3,
22 write_collection/1, mark_collection_deleted/3,
23 %% Searches
24 get_search/2, list_searches/2, list_search_versions/2, write_search/1, mark_search_deleted/3,
25 %% Tags
26 list_tags/2, list_item_tags/2, delete_tags_by_name/2, set_item_tags/3, delete_item_tags/2,
27 %% Settings
28 get_setting/2, list_settings/2, list_setting_versions/2, write_setting/1,
29 delete_setting/2,
30 %% Deleted
31 list_deleted/3, record_deletion/4,
32 %% Full-text
33 get_fulltext/2, list_fulltext_versions/2, write_fulltext/1, delete_fulltext/2,
34 %% File metadata
35 get_file_meta/2, write_file_meta/1, delete_file_meta/2,
36 %% Blobs (content-addressed)
37 blob_exists/1, blob_ref/1, blob_unref/1,
38 reset_orphan_blobs/0, reap_orphan_blobs/0,
39 %% Groups
40 get_group/1, list_groups/0, write_group/1, delete_group/1,
41 add_group_member/3, remove_group_member/2, get_group_member/2,
42 list_group_members/1, list_user_groups/1
43 ]).
44
45 %% ===================================================================
46 %% Libraries
47 %% ===================================================================
48
49 get_library(LibRef) ->
50 9 case db_read(shurbej_library, LibRef) of
51 9 [Lib] -> {ok, Lib};
52
:-(
[] -> undefined
53 end.
54
55 %% Ensure a library row exists — idempotent, called on user/group creation
56 %% and lazily on first access.
57 ensure_library(LibRef) ->
58 9 {atomic, ok} = mnesia:transaction(fun() ->
59 9 case mnesia:read(shurbej_library, LibRef) of
60
:-(
[] -> mnesia:write(#shurbej_library{ref = LibRef, version = 0});
61 9 _ -> ok
62 end
63 end),
64 9 ok.
65
66 update_library_version(LibRef, NewVersion) ->
67
:-(
db_write(#shurbej_library{ref = LibRef, version = NewVersion}).
68
69 %% ===================================================================
70 %% Users
71 %% ===================================================================
72
73 create_user(Username, Password, UserId) ->
74
:-(
Salt = crypto:strong_rand_bytes(16),
75
:-(
Hash = hash_password(Password, Salt),
76
:-(
{atomic, ok} = mnesia:transaction(fun() ->
77
:-(
mnesia:write(#shurbej_user{
78 username = Username,
79 password_hash = Hash,
80 salt = Salt,
81 user_id = UserId
82 }),
83
:-(
LibRef = {user, UserId},
84
:-(
case mnesia:read(shurbej_library, LibRef) of
85
:-(
[] -> mnesia:write(#shurbej_library{ref = LibRef, version = 0});
86
:-(
_ -> ok
87 end
88 end),
89
:-(
ok.
90
91 authenticate_user(Username, Password) ->
92 20 case db_read(shurbej_user, Username) of
93 [#shurbej_user{password_hash = Hash, salt = Salt, user_id = UserId}] ->
94 15 Computed = hash_password(Password, Salt),
95 15 case constant_time_compare(Computed, Hash) of
96 7 true -> {ok, UserId};
97 8 false -> {error, invalid}
98 end;
99 [] ->
100 5 _Dummy = hash_password(Password, crypto:strong_rand_bytes(16)),
101 5 {error, invalid}
102 end.
103
104 %% Constant-time binary comparison to prevent timing side-channels.
105 constant_time_compare(<<A, RestA/binary>>, <<B, RestB/binary>>) ->
106 15 constant_time_compare(RestA, RestB, A bxor B);
107
:-(
constant_time_compare(<<>>, <<>>) -> true;
108
:-(
constant_time_compare(_, _) -> false.
109
110 constant_time_compare(<<A, RestA/binary>>, <<B, RestB/binary>>, Acc) ->
111 465 constant_time_compare(RestA, RestB, Acc bor (A bxor B));
112 7 constant_time_compare(<<>>, <<>>, 0) -> true;
113 8 constant_time_compare(<<>>, <<>>, _) -> false.
114
115 get_user(Username) ->
116
:-(
case db_read(shurbej_user, Username) of
117
:-(
[User] -> {ok, User};
118
:-(
[] -> undefined
119 end.
120
121 get_user_by_id(UserId) ->
122 70 MS = ets:fun2ms(
123 fun(#shurbej_user{user_id = Id} = U) when Id =:= UserId -> U end),
124 70 case mnesia:dirty_select(shurbej_user, MS) of
125 68 [User | _] -> {ok, User};
126 2 [] -> undefined
127 end.
128
129 hash_password(Password, Salt) ->
130 60 {ok, DK} = pbkdf2(Password, Salt, 100000, 32),
131 60 DK.
132
133 pbkdf2(Password, Salt, Iterations, DkLen) ->
134 60 U1 = crypto:mac(hmac, sha256, Password, <<Salt/binary, 1:32>>),
135 60 Result = pbkdf2_loop(Password, U1, U1, Iterations - 1),
136 60 {ok, binary:part(Result, 0, DkLen)}.
137
138 60 pbkdf2_loop(_Password, _U, Acc, 0) -> Acc;
139 pbkdf2_loop(Password, U, Acc, N) ->
140 5999940 U2 = crypto:mac(hmac, sha256, Password, U),
141 5999940 pbkdf2_loop(Password, U2, crypto:exor(Acc, U2), N - 1).
142
143 %% ===================================================================
144 %% API Keys
145 %% ===================================================================
146
147 verify_key(Key) when is_binary(Key) ->
148 5 case db_read(shurbej_api_key, hash_api_key(Key)) of
149 4 [#shurbej_api_key{user_id = UserId}] -> {ok, UserId};
150 1 [] -> {error, invalid}
151 end;
152 verify_key(_) ->
153
:-(
{error, invalid}.
154
155 get_key_info(Key) ->
156 280 case db_read(shurbej_api_key, hash_api_key(Key)) of
157 [#shurbej_api_key{user_id = UserId, permissions = Perms}] ->
158 277 {ok, #{user_id => UserId, permissions => Perms}};
159 [] ->
160 3 {error, invalid}
161 end.
162
163 create_key(Key, UserId, Permissions) ->
164 45 db_write(#shurbej_api_key{
165 key = hash_api_key(Key), user_id = UserId, permissions = Permissions
166 }).
167
168 has_any_key() ->
169
:-(
mnesia:dirty_first(shurbej_api_key) =/= '$end_of_table'.
170
171 delete_key(Key) ->
172 2 db_delete({shurbej_api_key, hash_api_key(Key)}).
173
174 %% ===================================================================
175 %% Items
176 %% ===================================================================
177
178 get_item({LT, LI}, ItemKey) ->
179 28 case db_read(shurbej_item, {LT, LI, ItemKey}) of
180 25 [#shurbej_item{deleted = false} = Item] -> {ok, Item};
181 3 _ -> undefined
182 end.
183
184 list_items({LT, LI}, Since) ->
185 43 MS = ets:fun2ms(
186 fun(#shurbej_item{id = {T, I, _}, version = V, deleted = false} = Item)
187 when T =:= LT, I =:= LI, V > Since -> Item
188 end),
189 43 mnesia:dirty_select(shurbej_item, MS).
190
191 list_item_versions({LT, LI}, Since) ->
192
:-(
MS = ets:fun2ms(
193 fun(#shurbej_item{id = {T, I, K}, version = V, deleted = false})
194 when T =:= LT, I =:= LI, V > Since -> {K, V}
195 end),
196
:-(
mnesia:dirty_select(shurbej_item, MS).
197
198 write_item(Item) when is_record(Item, shurbej_item) ->
199 87 db_write(Item).
200
201 mark_item_deleted({LT, LI}, ItemKey, Version) ->
202 6 case db_read(shurbej_item, {LT, LI, ItemKey}) of
203 [Item] ->
204 6 db_write(Item#shurbej_item{version = Version, deleted = true});
205 [] ->
206
:-(
ok
207 end.
208
209 list_items_top({LT, LI}, Since) ->
210 1 MS = ets:fun2ms(
211 fun(#shurbej_item{id = {T, I, _}, version = V, deleted = false,
212 parent_key = undefined} = Item)
213 when T =:= LT, I =:= LI, V > Since -> Item
214 end),
215 1 mnesia:dirty_select(shurbej_item, MS).
216
217 list_items_trash({LT, LI}, Since) ->
218 2 MS = ets:fun2ms(
219 fun(#shurbej_item{id = {T, I, _}, version = V, deleted = true} = Item)
220 when T =:= LT, I =:= LI, V > Since -> Item
221 end),
222 2 mnesia:dirty_select(shurbej_item, MS).
223
224 list_items_children({LT, LI}, ParentKey, Since) ->
225 %% Secondary index on parent_key: O(k) instead of full table scan.
226 7 Candidates = db_index_read(shurbej_item, ParentKey,
227 #shurbej_item.parent_key),
228 7 [I || #shurbej_item{id = {T, Id, _}, version = V, deleted = false} = I
229 7 <- Candidates, T =:= LT, Id =:= LI, V > Since].
230
231 list_items_in_collection({LT, LI} = LibRef, CollKey, Since) ->
232 %% Bag table: O(1) key lookup returns all items in this collection.
233
:-(
Rows = mnesia:dirty_read(shurbej_item_collection, {LT, LI, CollKey}),
234
:-(
lists:filtermap(fun(#shurbej_item_collection{item_key = IK}) ->
235
:-(
case get_item(LibRef, IK) of
236
:-(
{ok, #shurbej_item{version = V} = Item} when V > Since -> {true, Item};
237
:-(
_ -> false
238 end
239 end, Rows).
240
241 count_item_children({LT, LI}) ->
242 %% Return only the parent_key field — no full data maps deserialized.
243 25 MS = ets:fun2ms(
244 fun(#shurbej_item{id = {T, I, _}, deleted = false, parent_key = PK})
245 when T =:= LT, I =:= LI, PK =/= undefined -> PK
246 end),
247 25 ParentKeys = mnesia:dirty_select(shurbej_item, MS),
248 25 lists:foldl(fun(PK, Acc) ->
249 47 maps:update_with(PK, fun(N) -> N + 1 end, 1, Acc)
250 end, #{}, ParentKeys).
251
252 %% ===================================================================
253 %% Collections
254 %% ===================================================================
255
256 get_collection({LT, LI}, CollKey) ->
257 3 case db_read(shurbej_collection, {LT, LI, CollKey}) of
258 2 [#shurbej_collection{deleted = false} = Coll] -> {ok, Coll};
259 1 _ -> undefined
260 end.
261
262 list_collections({LT, LI}, Since) ->
263 6 MS = ets:fun2ms(
264 fun(#shurbej_collection{id = {T, I, _}, version = V, deleted = false} = Coll)
265 when T =:= LT, I =:= LI, V > Since -> Coll
266 end),
267 6 mnesia:dirty_select(shurbej_collection, MS).
268
269 list_collection_versions({LT, LI}, Since) ->
270
:-(
MS = ets:fun2ms(
271 fun(#shurbej_collection{id = {T, I, K}, version = V, deleted = false})
272 when T =:= LT, I =:= LI, V > Since -> {K, V}
273 end),
274
:-(
mnesia:dirty_select(shurbej_collection, MS).
275
276 list_collections_top(LibRef, Since) ->
277
:-(
[C || #shurbej_collection{data = D} = C <- list_collections(LibRef, Since),
278
:-(
maps:get(<<"parentCollection">>, D, false) =:= false].
279
280 list_subcollections(LibRef, ParentKey, Since) ->
281
:-(
[C || #shurbej_collection{data = D} = C <- list_collections(LibRef, Since),
282
:-(
maps:get(<<"parentCollection">>, D, false) =:= ParentKey].
283
284 write_collection(Coll) when is_record(Coll, shurbej_collection) ->
285 7 db_write(Coll).
286
287 mark_collection_deleted({LT, LI}, CollKey, Version) ->
288 2 case db_read(shurbej_collection, {LT, LI, CollKey}) of
289 [Coll] ->
290 2 db_write(Coll#shurbej_collection{version = Version, deleted = true});
291 [] ->
292
:-(
ok
293 end.
294
295 %% ===================================================================
296 %% Searches
297 %% ===================================================================
298
299 get_search({LT, LI}, SearchKey) ->
300 1 case db_read(shurbej_search, {LT, LI, SearchKey}) of
301 1 [#shurbej_search{deleted = false} = S] -> {ok, S};
302
:-(
_ -> undefined
303 end.
304
305 list_searches({LT, LI}, Since) ->
306 6 MS = ets:fun2ms(
307 fun(#shurbej_search{id = {T, I, _}, version = V, deleted = false} = S)
308 when T =:= LT, I =:= LI, V > Since -> S
309 end),
310 6 mnesia:dirty_select(shurbej_search, MS).
311
312 list_search_versions({LT, LI}, Since) ->
313 2 MS = ets:fun2ms(
314 fun(#shurbej_search{id = {T, I, K}, version = V, deleted = false})
315 when T =:= LT, I =:= LI, V > Since -> {K, V}
316 end),
317 2 mnesia:dirty_select(shurbej_search, MS).
318
319 write_search(Search) when is_record(Search, shurbej_search) ->
320 7 db_write(Search).
321
322 mark_search_deleted({LT, LI}, SearchKey, Version) ->
323 1 case db_read(shurbej_search, {LT, LI, SearchKey}) of
324 [Search] ->
325 1 db_write(Search#shurbej_search{version = Version, deleted = true});
326 [] ->
327
:-(
ok
328 end.
329
330 %% ===================================================================
331 %% Tags
332 %% ===================================================================
333
334 list_tags({LT, LI} = LibRef, Since) ->
335 4 case Since of
336 0 ->
337 %% Full sync: single scan of the tag table by LibRef.
338 4 MS = ets:fun2ms(
339 fun(#shurbej_tag{id = {T, I, Tag, _}, tag_type = Type})
340 when T =:= LT, I =:= LI -> {Tag, Type}
341 end),
342 4 lists:usort(mnesia:dirty_select(shurbej_tag, MS));
343 _ ->
344 %% Incremental: build key set from changed items, single tag scan.
345
:-(
ItemKeySet = sets:from_list(
346
:-(
[K || {K, _V} <- list_item_versions(LibRef, Since)]),
347
:-(
MS = ets:fun2ms(
348 fun(#shurbej_tag{id = {T, I, Tag, IK}, tag_type = Type})
349 when T =:= LT, I =:= LI -> {Tag, Type, IK}
350 end),
351
:-(
AllTags = mnesia:dirty_select(shurbej_tag, MS),
352
:-(
lists:usort([{Tag, Type} || {Tag, Type, IK} <- AllTags,
353
:-(
sets:is_element(IK, ItemKeySet)])
354 end.
355
356 set_item_tags({LT, LI} = LibRef, ItemKey, Tags) ->
357 68 delete_item_tags(LibRef, ItemKey),
358 68 lists:foreach(fun({Tag, Type}) ->
359 6 db_write(#shurbej_tag{id = {LT, LI, Tag, ItemKey}, tag_type = Type})
360 end, Tags).
361
362 delete_item_tags({LT, LI}, ItemKey) ->
363 74 MS = ets:fun2ms(
364 fun(#shurbej_tag{id = {T, I, _, IK}} = Tag)
365 when T =:= LT, I =:= LI, IK =:= ItemKey -> Tag
366 end),
367 74 Existing = db_select(shurbej_tag, MS),
368 74 lists:foreach(fun(T) -> db_delete_object(T) end, Existing).
369
370 list_item_tags({LT, LI}, ItemKey) ->
371 1 MS = ets:fun2ms(
372 fun(#shurbej_tag{id = {T, I, Tag, IK}, tag_type = Type})
373 when T =:= LT, I =:= LI, IK =:= ItemKey -> {Tag, Type}
374 end),
375 1 mnesia:dirty_select(shurbej_tag, MS).
376
377 delete_tags_by_name({LT, LI}, TagNames) ->
378 1 TagSet = sets:from_list(TagNames),
379 1 MS = ets:fun2ms(
380 fun(#shurbej_tag{id = {T, I, Tag, _}} = Row)
381 when T =:= LT, I =:= LI -> {Row, Tag}
382 end),
383 1 AllTags = mnesia:dirty_select(shurbej_tag, MS),
384 1 Deleted = [begin db_delete_object(Row), Tag end
385 1 || {Row, Tag} <- AllTags, sets:is_element(Tag, TagSet)],
386 1 lists:usort(Deleted).
387
388 %% ===================================================================
389 %% Settings
390 %% ===================================================================
391
392 get_setting({LT, LI}, SettingKey) ->
393 5 case db_read(shurbej_setting, {LT, LI, SettingKey}) of
394 3 [Setting] -> {ok, Setting};
395 2 [] -> undefined
396 end.
397
398 list_settings({LT, LI}, Since) ->
399 3 MS = ets:fun2ms(
400 fun(#shurbej_setting{id = {T, I, _}, version = V} = S)
401 when T =:= LT, I =:= LI, V > Since -> S
402 end),
403 3 mnesia:dirty_select(shurbej_setting, MS).
404
405 list_setting_versions({LT, LI}, Since) ->
406 1 MS = ets:fun2ms(
407 fun(#shurbej_setting{id = {T, I, K}, version = V})
408 when T =:= LT, I =:= LI, V > Since -> {K, V}
409 end),
410 1 mnesia:dirty_select(shurbej_setting, MS).
411
412 write_setting(Setting) when is_record(Setting, shurbej_setting) ->
413 4 db_write(Setting).
414
415 delete_setting({LT, LI}, SettingKey) ->
416 1 db_delete({shurbej_setting, {LT, LI, SettingKey}}).
417
418 %% ===================================================================
419 %% Deleted tracking
420 %% ===================================================================
421
422 list_deleted({LT, LI}, ObjectType, Since) ->
423 25 MS = ets:fun2ms(
424 fun(#shurbej_deleted{id = {T, I, OT, Key}, version = V})
425 when T =:= LT, I =:= LI, OT =:= ObjectType, V > Since -> Key
426 end),
427 25 mnesia:dirty_select(shurbej_deleted, MS).
428
429 record_deletion({LT, LI}, ObjectType, ObjectKey, Version) ->
430 11 db_write(#shurbej_deleted{
431 id = {LT, LI, ObjectType, ObjectKey}, version = Version
432 }).
433
434 %% ===================================================================
435 %% Full-text
436 %% ===================================================================
437
438 get_fulltext({LT, LI}, ItemKey) ->
439 3 case db_read(shurbej_fulltext, {LT, LI, ItemKey}) of
440 1 [Ft] -> {ok, Ft};
441 2 [] -> undefined
442 end.
443
444 list_fulltext_versions({LT, LI}, Since) ->
445 3 MS = ets:fun2ms(
446 fun(#shurbej_fulltext{id = {T, I, K}, version = V})
447 when T =:= LT, I =:= LI, V > Since -> {K, V}
448 end),
449 3 mnesia:dirty_select(shurbej_fulltext, MS).
450
451 write_fulltext(Ft) when is_record(Ft, shurbej_fulltext) ->
452 2 db_write(Ft).
453
454 delete_fulltext({LT, LI}, ItemKey) ->
455 6 db_delete({shurbej_fulltext, {LT, LI, ItemKey}}).
456
457 %% ===================================================================
458 %% File metadata
459 %% ===================================================================
460
461 get_file_meta({LT, LI}, ItemKey) ->
462 50 case db_read(shurbej_file_meta, {LT, LI, ItemKey}) of
463 13 [Meta] -> {ok, Meta};
464 37 [] -> undefined
465 end.
466
467 write_file_meta(Meta) when is_record(Meta, shurbej_file_meta) ->
468 15 db_write(Meta).
469
470 delete_file_meta({LT, LI}, ItemKey) ->
471 %% Unref the blob before removing metadata. If the refcount hits 0 the
472 %% blob file needs unlinking from disk — we never do that inside the
473 %% transaction because file IO isn't rollback-safe (an aborted txn would
474 %% leave the metadata restored pointing at a missing file). Instead:
475 %% - inside a transaction: stash the hash so the transaction's driver
476 %% can unlink post-commit via reap_orphan_blobs/0
477 %% - outside: finish the transaction, then unlink.
478 6 case db_read(shurbej_file_meta, {LT, LI, ItemKey}) of
479 [#shurbej_file_meta{sha256 = Hash}] ->
480 2 case mnesia:is_transaction() of
481 true ->
482 2 case blob_unref_tx(Hash) of
483 2 0 -> mark_orphan_blob(Hash);
484
:-(
_ -> ok
485 end,
486 2 mnesia:delete({shurbej_file_meta, {LT, LI, ItemKey}});
487 false ->
488 %% Wrap read/unref/delete in one transaction, then unlink.
489
:-(
{atomic, Remaining} = mnesia:transaction(fun() ->
490
:-(
N = blob_unref_tx(Hash),
491
:-(
mnesia:delete({shurbej_file_meta, {LT, LI, ItemKey}}),
492
:-(
N
493 end),
494
:-(
case Remaining of
495
:-(
0 -> _ = file:delete(shurbej_files:blob_path(Hash));
496
:-(
_ -> ok
497 end,
498
:-(
ok
499 end;
500 [] ->
501 4 ok
502 end.
503
504 %% ===================================================================
505 %% Blobs (content-addressed store)
506 %% ===================================================================
507
508 blob_exists(Hash) ->
509 2 case db_read(shurbej_blob, Hash) of
510
:-(
[#shurbej_blob{}] -> true;
511 2 [] -> false
512 end.
513
514 blob_ref(Hash) ->
515 15 {atomic, ok} = mnesia:transaction(fun() ->
516 15 case mnesia:read(shurbej_blob, Hash, write) of
517 [#shurbej_blob{refcount = N} = Blob] ->
518 1 mnesia:write(Blob#shurbej_blob{refcount = N + 1});
519 [] ->
520 14 mnesia:write(#shurbej_blob{hash = Hash, size = 0, refcount = 1})
521 end
522 end),
523 15 ok.
524
525 %% Decrement the refcount on a blob. Returns the remaining count (0 means
526 %% the blob row was deleted — the file on disk still needs unlinking, which
527 %% is the caller's responsibility and must happen *after* the enclosing
528 %% transaction commits so an abort can't leave us referencing a missing file).
529 blob_unref(Hash) ->
530 1 {atomic, N} = mnesia:transaction(fun() -> blob_unref_tx(Hash) end),
531 1 N.
532
533 %% ===================================================================
534 %% Collection index
535 %% ===================================================================
536
537 set_item_collections({LT, LI} = LibRef, ItemKey, CollKeys) ->
538 68 delete_item_collections(LibRef, ItemKey),
539 68 lists:foreach(fun(CollKey) ->
540
:-(
db_write(#shurbej_item_collection{id = {LT, LI, CollKey}, item_key = ItemKey})
541 end, CollKeys).
542
543 delete_item_collections({LT, LI}, ItemKey) ->
544 74 MS = ets:fun2ms(
545 fun(#shurbej_item_collection{id = {T, I, _}, item_key = IK} = R)
546 when T =:= LT, I =:= LI, IK =:= ItemKey -> R
547 end),
548 74 Existing = db_select(shurbej_item_collection, MS),
549 74 lists:foreach(fun(R) -> db_delete_object(R) end, Existing).
550
551 %% ===================================================================
552 %% Groups
553 %% ===================================================================
554
555 get_group(GroupId) ->
556 40 case db_read(shurbej_group, GroupId) of
557 38 [Group] -> {ok, Group};
558 2 [] -> undefined
559 end.
560
561 list_groups() ->
562 2 mnesia:dirty_select(shurbej_group,
563 ets:fun2ms(fun(#shurbej_group{} = G) -> G end)).
564
565 write_group(Group) when is_record(Group, shurbej_group) ->
566
:-(
db_write(Group).
567
568 delete_group(GroupId) ->
569 %% Wipe group library data + membership; admin-only op, so no perm check.
570 %% Runs in a single Mnesia transaction so partial failure can't leave
571 %% orphaned items/tags/file_meta/etc. Freed blobs get unlinked from disk
572 %% only after the transaction commits, and the per-library version
573 %% server is shut down after everything has settled.
574 1 LibRef = {group, GroupId},
575 1 reset_orphan_blobs(),
576 1 Result = mnesia:transaction(fun() ->
577 1 cascade_delete_library(LibRef),
578 1 mnesia:delete({shurbej_group, GroupId}),
579 1 MemberMS = ets:fun2ms(
580 fun(#shurbej_group_member{id = {G, _}} = M) when G =:= GroupId -> M end),
581 1 [mnesia:delete_object(M) || M <- mnesia:select(shurbej_group_member, MemberMS)],
582 1 mnesia:delete({shurbej_library, LibRef}),
583 1 ok
584 end),
585 1 case Result of
586 {atomic, ok} ->
587 1 reap_orphan_blobs(),
588 1 shurbej_version_sup:terminate_child(LibRef),
589 1 ok;
590 {aborted, Reason} ->
591
:-(
reset_orphan_blobs(),
592
:-(
erlang:error({delete_group_failed, Reason})
593 end.
594
595 %% Delete every row belonging to the given library from every per-library
596 %% table, releasing blob references as we go. Must be called inside a
597 %% Mnesia transaction. Tables listed here must have their primary key
598 %% start with {LibType, LibId, ...} — we only guard on those first two
599 %% elements so the match spec is uniform.
600 cascade_delete_library({LT, LI}) ->
601 1 [delete_lib_rows(Table, LT, LI) || Table <- lib_tables_3tuple_key()],
602 1 [delete_lib_rows(Table, LT, LI) || Table <- lib_tables_4tuple_key()],
603 %% file_meta: release the blob ref first (which may mark it for
604 %% post-commit unlink), then delete the row.
605 1 FileMetaMS = ets:fun2ms(
606 fun(#shurbej_file_meta{id = {T, I, _}} = R) when T =:= LT, I =:= LI -> R end),
607 1 lists:foreach(fun(#shurbej_file_meta{sha256 = Sha256} = R) ->
608 1 case blob_unref_tx(Sha256) of
609 1 0 -> mark_orphan_blob(Sha256);
610
:-(
_ -> ok
611 end,
612 1 mnesia:delete_object(R)
613 end, mnesia:select(shurbej_file_meta, FileMetaMS)),
614 1 ok.
615
616 lib_tables_3tuple_key() ->
617 1 [shurbej_item, shurbej_collection, shurbej_search,
618 shurbej_setting, shurbej_fulltext, shurbej_item_collection].
619
620 lib_tables_4tuple_key() ->
621 1 [shurbej_tag, shurbej_deleted].
622
623 delete_lib_rows(Table, LT, LI) ->
624 %% Every library-scoped table puts the id tuple in position 2 of the
625 %% record (mnesia record attributes start at 2). Match specs work the
626 %% same for 3- and 4-element id tuples because we only bind the first
627 %% two positions.
628 8 MS = [{mk_row_pattern(Table), [{'=:=', {element, 1, {element, 2, '$_'}}, LT},
629 {'=:=', {element, 2, {element, 2, '$_'}}, LI}],
630 ['$_']}],
631 8 lists:foreach(fun(R) -> mnesia:delete_object(R) end,
632 mnesia:select(Table, MS)).
633
634 mk_row_pattern(Table) ->
635 %% A fully-wild record pattern — Table tagged, every field '_'.
636 8 Arity = length(mnesia:table_info(Table, attributes)),
637 8 list_to_tuple([Table | lists:duplicate(Arity, '_')]).
638
639 %% Orphan-blob bookkeeping. `delete_file_meta` and `cascade_delete_library`
640 %% record freed blob hashes here while running inside a Mnesia transaction;
641 %% the transaction driver (shurbej_version:do_write or delete_group) then
642 %% unlinks the files from disk after the transaction commits. On abort the
643 %% driver calls reset_orphan_blobs/0 instead so nothing gets unlinked.
644
645 -define(ORPHAN_PDICT_KEY, shurbej_orphan_blobs).
646
647 mark_orphan_blob(Hash) ->
648 3 put(?ORPHAN_PDICT_KEY, [Hash | orphan_blobs()]),
649 3 ok.
650
651 orphan_blobs() ->
652 107 case get(?ORPHAN_PDICT_KEY) of
653 104 undefined -> [];
654 3 L when is_list(L) -> L
655 end.
656
657 %% Clear the pending-unlink list. Use this before starting a transaction
658 %% whose failure should not trigger any blob deletions.
659 reset_orphan_blobs() ->
660 104 erase(?ORPHAN_PDICT_KEY),
661 104 ok.
662
663 %% Flush the pending-unlink list and delete each blob from disk. Call this
664 %% AFTER a successful transaction commit (never before — if the commit
665 %% aborts we must not unlink).
666 reap_orphan_blobs() ->
667 104 Hashes = orphan_blobs(),
668 104 erase(?ORPHAN_PDICT_KEY),
669 104 lists:foreach(fun(Hash) ->
670 3 _ = file:delete(shurbej_files:blob_path(Hash))
671 end, Hashes),
672 104 ok.
673
674 %% In-transaction blob unref — mirrors blob_unref/1 but without starting a
675 %% nested transaction. Returns the remaining refcount.
676 blob_unref_tx(Hash) ->
677 4 case mnesia:read(shurbej_blob, Hash, write) of
678 [#shurbej_blob{refcount = N} = Blob] when N > 1 ->
679
:-(
mnesia:write(Blob#shurbej_blob{refcount = N - 1}),
680
:-(
N - 1;
681 [#shurbej_blob{}] ->
682 4 mnesia:delete({shurbej_blob, Hash}),
683 4 0;
684 [] ->
685
:-(
0
686 end.
687
688 add_group_member(GroupId, UserId, Role) ->
689 8 db_write(#shurbej_group_member{id = {GroupId, UserId}, role = Role}).
690
691 remove_group_member(GroupId, UserId) ->
692 1 db_delete({shurbej_group_member, {GroupId, UserId}}).
693
694 get_group_member(GroupId, UserId) ->
695 13 case db_read(shurbej_group_member, {GroupId, UserId}) of
696 10 [Member] -> {ok, Member};
697 3 [] -> undefined
698 end.
699
700 list_group_members(GroupId) ->
701 2 MS = ets:fun2ms(
702 fun(#shurbej_group_member{id = {G, _}} = M) when G =:= GroupId -> M end),
703 2 mnesia:dirty_select(shurbej_group_member, MS).
704
705 list_user_groups(UserId) ->
706 10 MS = ets:fun2ms(
707 fun(#shurbej_group_member{id = {_, U}} = M) when U =:= UserId -> M end),
708 10 mnesia:dirty_select(shurbej_group_member, MS).
709
710 %% ===================================================================
711 %% Internal
712 %% ===================================================================
713
714 hash_api_key(Key) ->
715 332 crypto:hash(sha256, Key).
716
717 %% ===================================================================
718 %% Internal — transaction-aware Mnesia operations.
719 %% Uses mnesia:write/read/delete inside transactions, dirty_* outside.
720 %% ===================================================================
721
722 db_write(Record) ->
723 201 case mnesia:is_transaction() of
724 148 true -> mnesia:write(Record);
725 53 false -> mnesia:dirty_write(Record)
726 end.
727
728 db_read(Table, Key) ->
729 474 case mnesia:is_transaction() of
730 49 true -> mnesia:read(Table, Key);
731 425 false -> mnesia:dirty_read(Table, Key)
732 end.
733
734 db_delete(TableKey) ->
735 10 case mnesia:is_transaction() of
736 7 true -> mnesia:delete(TableKey);
737 3 false -> mnesia:dirty_delete(TableKey)
738 end.
739
740 db_delete_object(Record) ->
741 1 case mnesia:is_transaction() of
742 1 true -> mnesia:delete_object(Record);
743
:-(
false -> mnesia:dirty_delete_object(Record)
744 end.
745
746 db_select(Table, MS) ->
747 148 case mnesia:is_transaction() of
748 148 true -> mnesia:select(Table, MS);
749
:-(
false -> mnesia:dirty_select(Table, MS)
750 end.
751
752 db_index_read(Table, Key, Pos) ->
753 7 case mnesia:is_transaction() of
754 6 true -> mnesia:index_read(Table, Key, Pos);
755 1 false -> mnesia:dirty_index_read(Table, Key, Pos)
756 end.
Line Hits Source